@bravely-studios/account-web
v0.7.3
Published
Bravely Account web facade: OAuth 2.1 + PKCE sign-in, BAS lifecycle, entitlement cache, activation state machine, C-ux M2/M3/M4 components. Used by all 10 utility web variants + bravely.dev.
Downloads
947
Readme
@bravely-studios/account-web
Thin TypeScript facade over the Bravely identity API for browser-based Bravely Studios apps. Used internally across the Bravely web app family.
What it does
- OAuth 2.1 + PKCE sign-in via
auth.bravely.dev— RFC 7636 S256. - Session forever (0.4.0, D4) — durable IndexedDB session plus the
grant_type=refresh_tokenrotation loop: pre-expiry background refresh, 401 refresh-then-retry-once, and destructive sign-out ONLY on a definitiveinvalid_grant/ 401-after-refresh. Transport failures never wipe tokens. - Login-first screen (0.4.0, D1) —
<BravelySignInScreen>: icon + name- ONE value-prop line + the locked provider picker + passive legal links;
recoverable offline state;
onShownseam forlogin_screen_shown. Compact per-step geometry (0.4.1, D3 v1.2): the column caps atSIGN_IN_COMPACT_MAX_WIDTH(480px) — sign-in never inherits offer geometry.
- ONE value-prop line + the locked provider picker + passive legal links;
recoverable offline state;
- 3-slot offer grid (0.4.0, D3; 0.4.1 v1.2) —
<OfferSlotGrid>+ the locked slot-copy factory (buildOfferSlots), per-app themes, HEIGHT-FIRST full-viewport scale-to-fit INCLUDING scale-up (scale fillsviewportHeight × 0.92, width capped atviewportWidth × 0.95; clamp [0.5, 3.0], scroll fallback below, no max-width column cap), slot-card baseline row alignment (subgrid; CTA bottom-pinned),slot_viewed/slot_selectedhooks. - Install metrics (0.4.0, D6) —
getOrMintInstallId()+emitAppFirstOpenedIfNeeded()(persist-first fire-once sentinel; the host fires the actual analytics event). - Entitlement cache — 72h offline fallback.
- Activation state machine — checkout-to-active flow (v1.1.0: the
user_skippedlane is removed — no skip/guest affordance). - Paddle account actions — BAS-authed checkout session and
customer-portal session helpers through
identity.bravely.dev. Bravely-Deprecationhandling — soft warnings + hardBravelyClientKilledErroron HTTP 426 kill-switch.- DPoP-ready — RFC 9449 proof generation.
- Onboarding v2 (0.6.0) — the shared launch router, the
AccessStatetwo-clock rule, the 24-Hour No-Card Trial lane (startTrial/extendTrial/requestOnboardingPass/requestDownloadEmail), the headlessOnboardingFlowstep engine, the journey emitter, and the S1-S6 surfaces. See Adopting onboarding v2 below.
Install
npm install @bravely-studios/account-webUsage
import { BravelyAccountManager } from "@bravely-studios/account-web";
const manager = new BravelyAccountManager({
authority: "https://auth.bravely.dev",
appSlug: "diskaroo",
clientVersion: "1.3.1",
// Optional: capture SECRET-FREE breadcrumbs (HTTP status + step) from the
// auth / checkout / activation / sign-out failure paths into the host's
// diagnostic ring buffer. Omit for no-op (no behavior change).
log: (event, ctx) => DiagnosticLog.warn("bravely-account", event, ctx),
});
// On page load
await manager.restore();
manager.onStateChange((state) => {
if (state.kind === "signed_in") renderApp(state);
});
// On a sign-in button click
await manager.signIn();
// React 19? Subscribe via useSyncExternalStore — `getState` returns a stable
// reference between updates (0.2.1+), so no `_cachedState` workaround needed.
//
// const state = useSyncExternalStore(
// manager.onStateChange.bind(manager),
// manager.getState.bind(manager),
// );
// Entitlement gate
if (await manager.hasEntitlement("diskaroo_pro")) {
showProFeatures();
}
// Paid upgrade
await manager.openCheckout("annual");
// Manage subscription
const portal = await manager.createPaddlePortalSession();
window.open(portal.url, "_blank");Module map
| File | Responsibility |
|-----------------------------------------|-----------------------------------------------------------------------------------|
| BravelyAccountManager.ts | Public facade. Sign-in, sign-out, entitlements, checkout, portal, activation. |
| EntitlementCache.ts | 72h offline cache with TTL + invalidation. |
| ActivationStateMachine.ts | Port of the canonical activation machine. |
| oauth.ts | PKCE S256 helpers + authorize URL builder. |
| storage.ts | sessionStorage / IndexedDB / memory adapters. |
| dpop.ts | WebCrypto ES256 keypair + RFC 9449 proofs (Gate 2-ready). |
| deprecation.ts | Bravely-Deprecation header parser + error classes. |
| types.ts | TS types mirroring the OpenAPI 3.1 schemas. |
| displayName.ts | Slug → display-name lookup (diskaroo → Diskaroo). |
| components/ActivationLadder.tsx | M3 — post-checkout 4-phase ladder (Activating <App> Pro…). |
| components/CrossAppCard.tsx | M4 — third-quadrant card (You own N Bravely Pro apps on this account.). |
| components/BravelyProviderButtons.tsx | Canonical Apple/Google/Email picker — mirrors the Swift SwiftUI surface. |
| components/BravelySignInScreen.tsx | D1 — the login-first first screen (icon + name + value prop + picker). |
| components/OfferSlotGrid.tsx | D3 — locked 3-slot offer grid + copy factory + viewport fit (scale-up). |
| installMetrics.ts | D6 — install-id mint + fire-once app_first_opened sentinel helpers. |
| hooks/useActivationLaneFromUrl.ts | M3 — detect ?upgraded / ?checkout / ?subscription return params. |
| hooks/useFreshLaunchRestoration.ts | M2 — silent rehydrate + brief Synced N items from your <device>. toast. |
| version.ts | LIB_NAME / LIB_VERSION — the wire client version + lib_version on journey rows. |
| onboarding/launchRoute.ts | v2 — LaunchRoute + decideLaunch() (welcome → signIn → … → experience → chooser → app). |
| onboarding/accessState.ts | v2 — AccessState, the two-clock rule, TrialState/PassState, refresh sources. |
| onboarding/copy.ts | v2 — the ONE copy table + savePercent() + banned-phrase hygiene. |
| onboarding/journey.ts | v2 — JourneyEmitter, the allowlisted step vocabulary, claim-once first value. |
| onboarding/nudgePolicy.ts | v2 — the ≥4h trial nudge gate (persisted last_nudge_at). |
| onboarding/installState.ts | v2 — per-install firstRunCompleted (the welcome route's gate). |
| onboarding/flow.ts | v2 — the headless step engine + signals + host adapter + resume state. |
| hooks/useOnboardingFlow.ts | v2 — React bindings for the engine + useAnchorRect() for the coach mark. |
| components/WelcomeScreen.tsx | v2 S1 — lockup + ONE promise line + Get started. |
| components/TrialChooser.tsx | v2 S4 — the two-door chooser (See Offers Now / Try Now, No Card Required). |
| components/OffersPage.tsx | v2 S4b — the grid + headline + ← Back, and <FinalPaywall>. |
| components/TrialNudge.tsx | v2 S6 — the ≥4h nudge (slots + Keep Trying for Free). |
| components/TrialExpiryChooser.tsx | v2 S5 — first expiry: See Offers / +24 Hours. |
| components/TrialChip.tsx | v2 S6 — 24-Hour Trial · 19h 40m left + See offers. |
| components/CrossDeviceStep.tsx | v2 S3 — the corridor: email the link + the three ways (selectable URL). |
| components/GuidedStepOverlay.tsx | v2 S3 — the coach mark (scrim with a hole) + the 0.9s <SuccessBeat>. |
| components/onboardingChrome.tsx | v2 — surface tokens, the two button shapes, the account footer. |
C-ux M2/M3/M4 exports (0.2.0)
Wave A of the C-ux M2-M4 rollout (cux-m2-m4-rollout-plan.md). New exports
let the four D-Web variants — prodjectly, scry-web, printscreenly-web,
todoingly-web — mount the foundational surfaces in Wave B-D.
<ActivationLadder> (M3)
import { ActivationLadder } from "@bravely-studios/account-web";
<ActivationLadder
state={manager.getActivationState()}
appSlug="diskaroo"
orderId={paddleOrderId ?? null}
onRetry={() => manager.pollForActivation()}
onContactSupport={() =>
window.open("https://bravely.dev/contact?app=diskaroo&platform=web")
}
/>Renders nothing unless the manager is in post_checkout_activation.
Auto-ticks elapsed every second; rolls through the 4 locked phases at
0/15s/60s/120s. Phase copy is byte-identical to
bravely-commerce-router/docs/activation-state-machine.json — the
drift-test in __tests__/ActivationLadder.test.tsx enforces it.
<CrossAppCard> (M4)
import { CrossAppCard } from "@bravely-studios/account-web";
<CrossAppCard
entitlements={state.entitlements}
currentAppSlug="diskaroo"
variant="card" // or "footer-chip"
dismissible={false} // journey-doc default = persistent
/>Renders nothing when the user has zero cross-app entitlements. Excludes
the current app's own <slug>_pro from the count; treats
bravely_premium as a single bundle token.
useActivationLaneFromUrl() (M3)
const { inActivationLane, source, clearUrlParam } = useActivationLaneFromUrl({
manager,
autoStartPolling: true,
});
useEffect(() => {
if (inActivationLane) clearUrlParam();
}, [inActivationLane]);Detects the three observed post-checkout return URL patterns:
?upgraded=true (prodjectly), ?checkout=complete (scry-web),
?subscription=success (todoingly-web). Auto-calls
manager.notifyCheckoutCompleted() and, if autoStartPolling, kicks
off manager.pollForActivation().
<BravelyProviderButtons> (0.3.5)
import { BravelyProviderButtons } from "@bravely-studios/account-web";
<BravelyProviderButtons
style={{ variant: "dark", accent: "#0ea5e9" }}
isBusy={isAuthorizing}
onTap={(provider) => manager.signIn({ loginHint: provider })}
/>Drop-in 3-button provider picker (Apple / Google / Email) sized to the
hosted shell at auth.bravely.dev: 48px height, 12px radius, 10px stack
gap, 14px label. Web-mirror of the Swift BravelyProviderButtons
component, so the iOS/Mac/Web surfaces of the same app look identical.
Pure React + inline SVG; no UI-library or CSS-file dependency. Pass
style.accent as any CSS color string — color-mix handles the email
button tint at runtime.
useFreshLaunchRestoration() (M2)
const fresh = useFreshLaunchRestoration({
manager,
itemsLabel: "tasks",
resolveOtherDeviceName: () => null, // Gate 1 fallback
});
useEffect(() => {
fresh.setSyncedCount(myCollection.length);
}, [myCollection.length]);
return fresh.shouldShowToast ? <Toast>{fresh.toastText}</Toast> : null;First-launch detector. UI is silent for 3 s after sign-in; then Synced
N items from your <device>. shows for the host page to dismiss. The
banned phrase family (Welcome back. Restoring your Pro features) is
absent by design. Gate 1 device-name resolver is null; the hook drops
the from your <device> anchor automatically.
Manager additions
// Fetch the account-wide display list (0.6.6), excluding this app's key.
const others = await manager.refreshCrossAppEntitlements();
// The existing synchronous getter keeps its Entitlement[] shape:
const cachedOthers = manager.crossAppEntitlements();
// Both read the account display cache. Never use either as an unlock.
// Account-wide entitlement snapshot (0.3.6) — powers M4 cross-app awareness
// card and the Pro portfolio tile. Returns one row per app in the catalog.
const accountEnt = await manager.getAccountEntitlements();
// accountEnt.apps: AppEntitlement[] — one row per catalog app
// accountEnt.active_entitlements: string[] — all active lookup_keys family-wide
// accountEnt.subscription: SubscriptionInfo | null
// Post-checkout polling runner — 30 retries × 1s..8s capped backoff.
const result = await manager.pollForActivation();
// result.outcome: "active" | "exhausted" | "timeout" | "not_signed_in" | "unavailable"
// "unavailable" keeps verification pending and permits a retry.
// Paddle customer-portal session — callers decide how to open the URL.
const portal = await manager.createPaddlePortalSession();
// portal.url is the hosted customer-portal URL.Storage adapters
sessionStorage: PKCE verifier + state ONLY (single OAuth-dance secrets; they die with the tab by design).IndexedDB: one atomicsessionrecord holdsbas,refresh_token,ba_id,emailandexpires_at, withversion: 1. Legacy per-key rows are read only when this record is confirmed absent. The entitlement cache, DPoP thumbprint and install metrics also live here. The signing key remains page-scoped; a durable key is deferred until the pre-strict work.localStorage: the bounded Journey outbox (journey_pending) and its deferred-discard marker (journey_discard_pending), scoped to the app and installation. No session credentials are stored in it.- In-memory: explicit test storage or an unavailable-browser fallback. A failed session write retains replacement credentials for this page and retries persistence on subsequent session reads. Sign-in still succeeds.
manager.getStorageStatus() reports { available, durability }, where
durability is persistent | session | memory | unknown. Custom adapters may
implement optional getStatus(key?); omitted status means unknown durability.
A silent browser fallback reports unavailable memory. An unread session/cache
holds restoration; it never means signed out. A dirty replacement session
reports usable memory and logs session_persistence_unavailable through the
configured logger. Closing the page cannot recover credentials that were
never durably written. Browser opens and transactions have 5-second bounds.
Host pages can swap in their own ServiceWorker-backed adapter by passing
storage into the manager config.
Activation state machine
getActivationState() returns the current state from the canonical machine.
Host pages render UI off the name (restoring_session, verifying_entitlement,
entitlement_cached_valid, post_checkout_activation, etc.) and the
busy flag (whether to show a spinner). CLAUDE.md hard rule
feedback_no_etas: never render a predicted ETA — always elapsed time.
DPoP gate transition
- Gate 1 (today): BAS-authed manager requests keep
Authorization: Bearer <bas>for router compatibility and also attach a validDPoPproof header withath. The server runs inoffmode and accepts the Bearer scheme without verifying the proof. - Gate 2 (next): server enforcement can start from real client traffic
because
getAppDataToken(),openCheckout(),createPaddlePortalSession(), entitlement refreshes, and activation polls already carry proof headers.
Login-first cutover surfaces (0.4.0)
<BravelySignInScreen> (D1)
import { BravelySignInScreen, getOrMintInstallId } from "@bravely-studios/account-web";
<BravelySignInScreen
appName="Todoing.ly"
appIconSrc="/icon-256.png"
valueProp="Every task, every device, always in sync."
style={{ variant: "dark", accent: "#3B6EF0" }}
isBusy={isAuthorizing}
onContinue={(provider) => manager.signIn({ provider })}
offline={cantReach}
onRetry={() => retryProbe()}
onShown={async () => {
posthog.capture("login_screen_shown", { install_id: await getOrMintInstallId() });
}}
/>The first screen on first launch (spec onboarding.login_first). No skip,
no guest lane, no sign-in/sign-up fork — the screen IS both. onShown
fires once per mount; the host MUST emit login_screen_shown there.
<OfferSlotGrid> + buildOfferSlots() (D3)
import {
OfferSlotGrid, buildOfferSlots, offerThemeForSlug,
planTokenForSlot, telemetryValueForSlot,
} from "@bravely-studios/account-web";
const slots = buildOfferSlots({ appName: "Scry", appSlug: "scry", type: "RVA" });
<OfferSlotGrid
slots={slots} // provisioned slots only — filter before passing
theme={offerThemeForSlug("scry")}
onSlotViewed={(s) => posthog.capture("slot_viewed", { offer_slot: telemetryValueForSlot(s) })}
onSelect={(s) => {
posthog.capture("slot_selected", { offer_slot: telemetryValueForSlot(s) });
manager.openCheckout(planTokenForSlot(s, "RVA"));
}}
/>The web sibling of Swift OfferSlotGrid / C# BravelyOfferGrid: the locked
3-slot copy (HOOK → GSO → FRONT_LTV) with HEIGHT-FIRST full-viewport
scale-to-fit including scale-up (v1.2: the scale fills
viewportHeight × 0.92; width binds only as a cap at
viewportWidth × 0.95; clamped to [0.5, 3.0]; scroll fallback below 0.5;
single-column stack under 901px; NO max-width column cap). In the 3-up
layout the cards share row tracks (CSS subgrid) so every section row
baseline-aligns to its tallest sibling and the CTAs pin to the card bottom.
The host fires paywall_shown when it presents the page.
Install metrics (D6)
import { getOrMintInstallId, emitAppFirstOpenedIfNeeded } from "@bravely-studios/account-web";
// At app entry, before any UI gating:
const { installId } = await emitAppFirstOpenedIfNeeded({
emit: ({ installId }) => posthog.capture("app_first_opened", { install_id: installId }),
});
manager.setInstallId(installId); // every auth exchange now carries install_idPersist-first sentinel: the durable IndexedDB sentinel row is written BEFORE the emit, so a crash can only under-count — never double-fire.
Adopting onboarding v2 (0.6.0)
The "solve once" layer for the family onboarding overhaul. Contracts:
05 - Business/Bravely/onboarding-v2/contracts/lib-contract.md (this API),
api-contract.md (the server shapes),
00 - AI Instructions/onboarding-v2-trial-model.md §3-§7 (behavior + the copy
bank), onboarding-v2-experiences.md (the per-app step lists),
onboarding-v2-journey-capture.md (the telemetry).
Nothing here is opt-in copy. Strings come from ONE table
(onboardingCopyFor(appName)); a host may not pass its own. Prices drive the
saving (savePercent), so "20%" is never typed into a string.
1. Wire the journey once, at the root
You do NOT wire tokens, a sink, or an analytics transport for the journey. The lib owns it. Take the emitter off the manager and provide it:
import { JourneyProvider } from "@bravely-studios/account-web";
<JourneyProvider journey={manager.journey}>{children}</JourneyProvider>;That is the whole integration. manager.journey posts every step itself to
POST /api/analytics/events on the same authenticated transport
getEntitlements uses — Authorization: Bearer <BAS> plus the DPoP proof,
with the bearer read from storage before each send. Before sign-in,
welcome_shown, login_screen_shown, and all other steps wait locally in a
queue of at most 32 rows; overflow drops the oldest. The default browser
adapter persists this installation's queue so Welcome can survive a reload.
The manager drains it on session restoration/sign-in, before later steps,
with each row's original ISO client_ts. journey.flush() also attempts a
drain; with no bearer it leaves the rows queued. Disabled Journey queues and
posts nothing. Custom sinks/transports continue to own their authentication
and delivery behavior.
Every v2 surface then emits its own step with install_id, platform: "web",
app_version, lib_version and client_ts at the body root. Capture is
guaranteed by construction — you cannot adopt the flow and skip the telemetry.
Why this changed in 0.6.2. Until 0.6.1 this section told you to pass a
sinkpointed at "your PostHog +/api/analytics/eventspath". That advice was the bug. The router writes ajourney_eventsrow only for an authenticated post (handleNativeAppEventgates the insert onbaIdVerified), and no app's analytics transport carries the BAS bearer. An unauthenticated post still returns 200, still lands an ordinaryanalytics_eventsrow, and raises no error, so every app wired as documented shipped green and reported an empty journey funnel. Delete any journey sink, session-token provider, or "point the journey at our analytics" wiring you added; there is nothing to replace it with.
JourneyEmitterConfig.sink still exists, and supplying one REPLACES the
built-in post rather than running alongside it (two transports would double
every row, and a doubled impression halves every conversion rate computed from
it). Use it to observe emission in a test, or if you genuinely have an
authenticated transport of your own. Otherwise, do not pass it.
One row is yours to fire, because no component owns it:
journey.emitAppLanded(manager.getAccessState().kind); // once, when the app rendersmanager.journey.enabled = false disables Journey locally (default on):
discard pending rows and prevent new queue additions, posts, install-id lookups
and claim-once writes. An unread queue retains a deferred-discard marker until
it can be read and purged. Construction also accepts
new JourneyEmitter({ enabled: false, ...config }). Re-enabling permits new
events; it does not replay discarded queued events. flush() remains available.
Since 0.6.5, app_landed is claimed once per browser installation, before
sending, in the existing onboarding_state row. Reloads, remounts and account
changes keep the claim. flow.reset() drains pending journey work and clears
that row; a new emitter can then claim again. The per-run guard avoids repeated
storage reads. If the claim cannot be persisted, the landing is dropped; other
journey steps remain fail-soft.
In 0.6.8, a failed first-value claim read/write retains the action and its
original timestamp in memory for retry on flush(). It returns false until the
claim is committed. An unread pre-auth outbox keeps disk records intact and
reconciles page additions only after a successful read. Unread nudge history
suppresses nudges; direct readLastNudgeAt() callers must handle its rejection.
Before navigation or teardown that your app controls, await the queue:
manager.journey.emitAppLanded(manager.getAccessState().kind);
await manager.journey.flush();
// Now navigate or tear down the page.flush(): Promise<void> drains pending claims and the ordered, one-at-a-time
send queue. It never rejects. An optional browser fallback is:
const flushJourney = () => { void manager.journey.flush(); };
window.addEventListener("beforeunload", flushJourney);
// On cleanup: window.removeEventListener("beforeunload", flushJourney);Browsers do not await promises returned by unload handlers. Await flush()
before initiating navigation where possible; the built-in transport also uses
fetch with keepalive: true. Abrupt closure or offline delivery can still
lose a claimed row; persist-first prevents that failure from duplicating it.
The rest of the spine is lib-owned. manager.startTrial() emits
trial_started itself; <BravelySignInScreen> emits login_screen_shown; and
if you render your own sign-in surface, flow.emitLoginScreenShown() /
flow.emitSignInCompleted(provider, isNewUser) spell the names for you. There
is deliberately no emitTrialExtended(): trial_extended is server-written
(the router does not accept it from a client), so a successful extendTrial()
emits nothing.
CORS. The browser posts straight to identity.bravely.dev; the router's
preflight allows Authorization and DPoP, and its origin allowlist is
https://bravely.dev, any *.bravely.dev, and localhost/127.0.0.1. An app
served from a short brand domain or a customer custom domain gets no CORS
headers and the browser blocks the post — which fails soft (emission never
throws) but reports nothing, so keep app surfaces on <app>.bravely.dev.
A storage failure (private window, blocked site data) costs the durable install id, never the row — the emitter falls back to an in-memory one.
Tests inject storage. Every storage-backed seam takes one:
new JourneyEmitter({ storage }), new OnboardingFlow({ storage }),
new NudgePolicy({ storage }), new BravelyAccountManager({ storage }),
getOrMintInstallId(storage), isFirstRunCompleted(storage). Pass
memoryStorage() in tests so a run never touches the real first-run,
first-value or last-nudge sentinels.
1b. Server-safe entry
Anything without React — a Next.js Route Handler, an RSC module, a script — imports the subpath instead:
import { decideLaunch, onboardingCopyFor } from "@bravely-studios/account-web/server";The root entry pulls in the surfaces, and journeyContext.tsx calls
createContext at module scope, which kills next build when a route handler
imports it. /server carries the manager, routing, access state, copy, the
journey emitter + transport, install metrics, storage, DPoP and the activation
state machine — and a test walks its import graph to fail if any of it ever
reaches React.
2. Route the launch
import {
decideLaunch,
isFirstRunCompleted,
markFirstRunCompleted,
} from "@bravely-studios/account-web";
const decision = decideLaunch({
firstRunCompleted: await isFirstRunCompleted(),
isSignedIn: manager.getState().kind === "signed_in",
sessionUnavailable: manager.getState().kind === "session_unavailable",
access: manager.getAccessState(),
freshSignIn, // true only after a completed sign-in this session
consentRequired, // your jurisdiction check
});decision.route is one of welcome | signIn | signInAccountAware |
consentConfirm | experience | trialChooser | app. decision.provisional === true means
verification has not resolved. This includes unread session/cache storage and
a refresh-only session awaiting a recoverable refresh. Render your neutral
verifying state (nothing gated, no paywall) and retry restore() when storage
or connectivity recovers. Carry session_unavailable through host wrappers;
the router flag holds even before the first-run/auth gates. signInAccountAware
is the ADR-0032 re-entry screen: pass signedInEmail + onContinueToPlans +
onUseDifferentAccount to <BravelySignInScreen>; never route a relaunching
unentitled customer straight to the paywall.
The convenience helpers decideLaunchRoute() and routeAfterExperience()
return LaunchRoute | "hold" in 0.6.8. Handle hold explicitly; callers that
need the full decision can use decideLaunch() or decideOnboarding().
A non-answer must never overwrite an answer — and the money surface is a
PRECONDITION, not a pending flag. An unresolved entitlement read (a page
navigation, a transient failure, a refresh in flight) derives unknown, and
routing unknown as if it were a verdict breaks in both directions: it hands a
paid app to an unentitled customer, and it flashes a paywall at a payer. Take
the access state from manager.getAccessState() (or run your own
deriveAccessState calls through AccessStateTracker), which keeps the last
RESOLVED answer while nothing new has landed and re-derives it against the
current clock so the two-clock rule still holds.
Then gate the render on the DECISION, never on a "loading" boolean:
const decision = decideLaunch({ /* … */ });
if (decision.provisional) return <YourNeutralVerifyingState />; // no paywall, nothing gated
switch (decision.route) { /* … */ }The money surface renders because routing said so. A host that renders it whenever a flag is not yet false will show it during every verification pass.
subscription.active does not mean "they bought it" (0.6.3). The FREE
24-hour no-card trial is mirrored into RevenueCat as a promotional
entitlement, and the router reports that back as
subscription: { active: true, platform: "promotional", expires_at: <the
trial's expiry> }. Admin comps look the same. So the block is only ownership
when a real store and this app's key establish ownership. Use
manager.getAccessState() or deriveAccessState() for that decision; the
library checks the store and app scope together. isPaidSubscription(active,
platform) classifies store evidence only.
In 0.6.5 the subscription also needs this app's <slug>_pro key or
bravely_premium; another app's subscription does not establish ownership.
isPaidSubscription() retains its two-argument signature and checks the store
only. Hosts using the manager's access/activation verdict need no additional
store-plus-app-key check. Router 0.11.9 scopes subscription, override and
active_entitlements to ?app=; the account-wide read is display data only.
The persisted entitlement row now keeps its account/app identity, trial/pass
clocks, subscription evidence, override flag and router fetched_at together.
A legacy key-only cache is unresolved until confirmed. Known temporary expiry
wins even after 72 hours; real cached purchases retain their 72-hour allowance.
Inline auth keys without ownership evidence cannot create a purchased cache.
Reads preserve a live local trial when their router timestamps cannot describe
that grant, including a missing trial block. A newer authoritative revocation
still applies. Stale start/extension conflicts trigger a fresh confirmation.
Named trial, extension, checkout, activationPoll and offerOpen reads
send X-Bravely-Activation-Poll: 1; routine launch, auth, restore, gate
and managerState reads retain the edge cache. One offerOpen means one
open/resume, never a polling source, and the bypass HEADER is floored at
MONEY_SURFACE_BYPASS_FLOOR_MS (10s) so an alt-tab storm or a remounting effect
cannot spend the account's router budget; a floored open still reads, it just
takes the edge copy. The post-grant sources are never floored. A limited 200
uses the cached answer and
ends that open's read. A 429 retries plain after Retry-After at the bounded
ladder's pace, keeping the previous access answer (or unknown). Activation
keeps its own cadence, uses limited bodies and never treats a 429 as an
inactive entitlement. Legacy confirmation: true without a named source keeps
its existing header behavior.
Activation polling confirms ownership with the same app/store rule, an explicit override, or the universal bundle key (including lifetime). A live pass, trial, promotional mirror, unrelated purchase or unqualified bare app key cannot complete checkout. Per-app lifetime purchases require store/override provenance; a bare key beside temporary access is insufficient evidence.
decision.reason === "reconnect_required" is the other one to handle: the
cache is past 72h and no read has landed, so we do NOT know they are
unentitled. Same screen, different sentence — show "reconnect to continue", not
"No active plan found".
Call markFirstRunCompleted() when the customer taps Get started (the flow
engine also does it when S3 finishes).
The routing table resolves in this order (0.6.2). Two rules used to contradict each other on "pass active + purchased"; this is the resolution:
- Entitled / purchased? First run ⇒
experience, with every step optional, then the app. Not first run ⇒app. - Else onboarding pass active? The 60-minute pass is never a route to the
app — it exists only so S3's server-gated first-value steps work
(
onboarding-v2-trial-model.md§6/§7). Experience incomplete ⇒experience; experience complete ⇒trialChooser. - Else the unentitled rules above.
experience is the new route: the person is signed in and past S1, so
re-showing Welcome would ask them to start something they are already inside.
Route it to the same S3 surfaces welcome leads into.
3. Run the experience
const flow = new OnboardingFlow({
config: {
appSlug: "printscreenly",
appName: "PrintScreen.ly",
promise: "Screenshots and recordings that look right the first time.",
valueType: "RVA",
steps: [
{ kind: "permission", id: "screen", permission: "screen_recording", title: "…", body: "…" },
{
kind: "guided",
id: "capture",
title: "Take your first screenshot",
ask: "Press",
hotkey: { display: settings.captureHotkey }, // the LIVE binding, never hardcoded
anchor: { id: "capture-button" },
signal: "first_capture",
},
],
},
journey,
});
await flow.start();
// …from your capture pipeline, when the shot actually lands:
flow.signals.fire("first_capture");
void flow.reportFirstValue("first_capture"); // claim-once per installRender with useOnboardingFlow(flow) and <GuidedStepOverlay> /
<CrossDeviceStep>; use useAnchorRect(el) to feed the spotlight a live rect.
A guided step advances ONLY when its signal fires — wire the signal end to end
or the step is a dead end.
Fire it whenever it happens; the engine cannot lose it (0.6.4). The
subscription is armed BEFORE the step is persisted, before host.present() and
before the awaited onboarding_step_shown post, and a signal that arrives while
that presentation is still in flight is held and honored the moment it finishes.
So firing from inside your own present, or from a listener that runs during
the telemetry post, is safe. What is still true: a signal fired BEFORE its step
is shown does not pre-complete it — the bus is per-occurrence on purpose.
After await flow.hydrate(), call flow.decide(manager.getAccessState()).
This uses Swift's post-auth table and grants owners/active trials Skip on every
step, including permissions. flow.skip() otherwise requires an optional
step. The pure forms are OnboardingFlow.decide(access, state, hasSteps) and
decideOnboarding(access, experienceCompleted, hasSteps). Unfinished experiences
run for every access state; afterward only owners/active trials reach the app.
unknown and reconnectRequired answer "hold": no answer has arrived, so the
host keeps its neutral verifying state and decides again. decideLaunch and
routeAfterExperience have no room for a fourth value, so they render the hold
as the app route with provisional: true — never the chooser, and never the
account-aware "No active plan found" screen.
flow.start() never replays a completed experience; flow.reset() is the
explicit replay.
A crossDevice step with target: "mobile" asks which phone and emails the
store links; target: "desktop" asks which computer. Wire onEmail straight
to manager.requestDownloadEmail({ platform, context, target }).
After it finishes, routeAfterExperience(manager.getAccessState()) sends the
customer to the app or to the chooser.
4. The money surfaces
Use this headless lifetime handle once for each wall, chooser, expiry chooser
or final paywall. It owns one read on open and one per browser resume, with
visibility/focus deduplicated. Close it when the surface leaves; a custom host
can call resume() for its own lifecycle. Existing source: "offerOpen" calls
remain supported when the host supplies one per open/resume.
const money = manager.openMoneySurface();
await money.ready;
// Re-read manager.getAccessState() before choosing the screen.
// Subscribe to manager.onStateChange for resume results.
// On the surface's cleanup:
money.close();// S4 — the chooser. `tryNowAvailable` is false once a no-card grant exists.
<TrialChooser
appName="PrintScreen.ly"
tryNowAvailable={manager.trial?.kind !== "no_cc_24h"}
onSeeOffers={() => setScreen("offers")}
onTryNow={async () => {
const r = await manager.startTrial({ platform: "web" });
if (r.outcome === "granted") return setScreen("app");
if (r.outcome === "already_used") return setScreen("expiry"); // S5 rules
if (r.outcome === "already_entitled") return setScreen("app");
}}
style={{ variant: "dark", accent: "#ff6b6b" }}
email={email}
/>
// S4b — the offers page. Confirm on open, then render.
await manager.getEntitlements({ source: "offerOpen" });
<OffersPage
appName="PrintScreen.ly"
slots={buildOfferSlots({ appName: "PrintScreen.ly", appSlug: "printscreenly", type: "RVA" })}
theme={offerThemeForSlug("printscreenly")}
style={style}
from="chooser"
onBack={() => setScreen("chooser")}
onSelect={(slot) => manager.openCheckout(planTokenForSlot(slot, "RVA"))}
/>With manager.journey, built-in offer, final-wall and nudge impressions
include intro_eligibility without host properties: used for store-intro or
legacy trials and a remembered real-store subscription serving this app's key;
eligible for an explicit no-trial wire with no such history; otherwise
unknown. manager.getIntroEligibility() exposes the same local verdict.
An explicit impressionProps.intro_eligibility overrides it on OffersPage,
FinalPaywall, or TrialNudge. This is impression context, never an unlock.
The page renders the headline and nothing else above the grid. There is no
default sub-headline: a shared money surface cannot make a platform or feature
claim on behalf of nine different apps. Pass subhead if YOUR app has a true
one.
from is where the customer came FROM, and it is a measurement, not a label —
pick the true one. chooser / nudge / expiry / chip are the v2 trial
surfaces; final is the post-extension wall. in_app (0.6.3) is any entry
from inside the app that is none of those — Settings ▸ Subscription ▸ See
plans is the canonical one:
<OffersPage from="in_app" onBack={closeSheet} /* … */ />It renders as a SHEET (Close, not "← Back") and its Close emits no
offers_back, so onBack is required — a sheet with no way out is a wall.
Never report one of these as chip to reuse an existing value: the chip funnel
measures one specific trial affordance, and Settings visits mixed into it
inflate chip impressions, deflate chip conversion, and cannot be split back out
afterwards.
S6: mount <TrialChip> while accessState.kind === "trialActive", and gate the
<TrialNudge> with NudgePolicy (≥4h in, ≥4h since the last, once per session,
never mid-action). S5: <TrialExpiryChooser> while
trialExpired.extensionAvailable, <FinalPaywall> after that.
5. What you must NOT do
- No fourth tile in the grid. The chooser and the no-card door are SIBLING
screens (
OfferSlotGridstays exactly three slots). - No "maybe later", "skip for now", "continue free", or "all 9 apps for $95.99"
—
findBannedPhrases()is the check, and it also bans vendor names. - Never call the trial "free pass", and never call the onboarding pass a trial.
- Never gate S3 behind an entitlement — use
requestOnboardingPass()for a server-gated first-value step. - Never render a money surface while
accessState.kind === "unknown". - Never hardcode a hotkey; read the app's live binding.
- Never put an em dash in customer copy, and never state a platform or feature
claim you have not grepped. The both-theme render sweep in
__tests__/onboardingSurfaces.test.tsxis the check, and since 0.6.1 it renders the offer CARDS too — there is no locked-copy exemption left. - Never wire your own journey sink, session-token provider, or analytics transport for the journey. The lib owns the authenticated post; a sink turns it OFF (see §1).
- Never say "Restore purchases". It is "Sync purchases", family-wide
(
bravely-account-user-journey.mdanti-pattern #15) andfindBannedPhrases()now covers the fixed spellings. - Never open the chip's offers page without
onBack: §4 makes it a SHEET, the component renders that affordance as Close for the chip origin, and the Close is what produces the lane's only terminal journey row.in_appis a sheet too and needsonBackfor the same reason. - Never read
subscription.activeas ownership on its own — the FREE no-card trial and every admin comp report{active: true, platform: "promotional"}. UseisPaidSubscription(active, platform)(§2). - Never borrow another surface's
OffersOriginfor an entry that has its own. A wrongfromis not a cosmetic error; it corrupts the funnel it lands in, permanently and un-splittably. - Never count the family up for the customer. They buy ONE app and get a ton
of free stuff, so the pitch is " Pro for a year, plus all 8 other
Bravely apps, free" — never "9 apps in all" / "all 9 apps for $95.99".
findBannedPhrases()covers both. - Never paint light-mode TEXT or a STROKE with the raw accent, and never fill
with
accentInk. Fill takesaccent, ink takesaccentInk(light-mode-color-ladder.md§3). The lib derives the light ink for you —surfaceTokens()for the chrome,offerLightInks()for the offer roles — so you only passaccentInkyourself if you MEASURED your own greys.
6. Checking the light theme
Both themes ship on every surface, and light is opt-in (never inherited from
the OS). Every web app exposes the same review affordance: append
?ov2theme=light to any onboarding v2 URL for the light set and
?ov2theme=dark for the dark one. Sticki.ly is light-first, so there the
parameter you need is ?ov2theme=dark.
Since 0.6.4 the light variant DERIVES its ink from your accent instead of
substituting a neutral, so what you should see with ?ov2theme=light is the
brand colour, deeper — accent-tinted text and strokes that are still recognisably
yours, an accent-ink hairline around the primary CTA, and no change at all to a
fill. Anything that reads as washed-out yellow-on-white, or as a colour that is
not the app's, is a bug: lightInk(accent) is the value the surface should be
using, and it is exported so you can check one in a console.
Diagnostics v2: bravelyDiagnostics (0.7.0, pin 0.7.3 or later)
What a "Send Diagnostic Report" upload carries, built once for every web app
(the family's diagnostic-report-v2 spec, §8.5 for web): the breadcrumb trail
(the app's analytics events, the journey, screens, marks), ui_state (the
working surface when the report is taken), env, the errors ring with problem
codes, and the one redactor. It is the web port of bravely-account-msal's
BravelyDiagnostics, and it passes the reference's 61 redaction vectors and
§3.6 breadcrumb vectors in this repo's tests. Every
call is synchronous and in memory, never throws, and is exported from both
@bravely-studios/account-web and @bravely-studios/account-web/server.
The library does these itself; apps wire nothing for them: the journey's
own steps (src: lib, recorded before the journey's consent and transport
gates), the signed-in account and access_state (the manager attaches itself),
network (navigator.onLine), page lifecycle (visibilitychange),
focus_kind (document.activeElement), env (display_px is the screen in
CSS pixels, display_scale is devicePixelRatio including the browser's zoom,
and the UTC offset), and the family vocabulary. Web has no freeze watchdog and no
prev_session (§8.5, §12), so there is no recordCrash, markCleanExit or
library-started auto report here.
An app wires these (v2 §11):
import {
bravelyDiagnostics, filterBreadcrumb, mergeVocabulary, familyVocabulary, REPORT_SURFACE_ATTRIBUTE,
} from "@bravely-studios/account-web";
// 1. Once at startup, before the first event: the app's §4.x vocabulary (code constants only).
bravelyDiagnostics.configure("asapdf", ASAPDF_DIAGNOSTICS_VOCABULARY);
// 2. The FIRST line of the analytics choke point, above the opt-out check, with the caller's props.
export function track(event: string, props?: Record<string, unknown>): void {
bravelyDiagnostics.recordEvent(event, props);
if (optedOut) return;
// …
}
// 3. The navigation owner: a root-layout effect mapping route PATTERNS (never a filled-in path) to tokens.
// Settings, Help and About routes push nothing (§4.3): the report keeps the working screen.
useEffect(() => {
const token = screenTokenFor(pathname); // null for /settings, /help, /about
if (token) bravelyDiagnostics.setScreen(token);
}, [pathname]);
// The library's onboarding screens do NOT call setScreen: push their tokens where you show them.
bravelyDiagnostics.setScreen("trial_options"); // and sign_in, offers, trial_expiry, final_wall (hold, welcome, onboarding)
// 4. The ui_state provider: reads the app-level store, memory only.
bravelyDiagnostics.setUiStateProvider(() => ({
modal: "none", modes: [], active_tool: tool, selection_kind: "none", doc_state: docState,
}));
// 5. The log facade: raw text in for warn and error, redacted text stored; drop lines from a previous account.
function logWarn(category: string, raw: string, code?: string, error?: unknown): void {
bravelyDiagnostics.recordProblem("warn", category, raw, code, error);
ring.push({ ts: new Date().toISOString(), level: "warn", category, msg: bravelyDiagnostics.redact(raw) });
}
bravelyDiagnostics.onAccountChanged((before) => ring.dropAtOrBefore(before)); // and any line with no readable ts
// 6. Every mode gate that drops or downgrades input, and the safety net once.
bravelyDiagnostics.mark("input_ignored", { reason: "field_review", target: "field" });
bravelyDiagnostics.installUnhandledInputNet();
// 7. The uploader (v2 §6): v1 extra keys + the report's v2 keys, the merged log, diagnostic.json.
const report = bravelyDiagnostics.buildReport("manual", analyticsOptedOut);
const metadataExtra = report.metadataExtraWith(v1Extra);
const diagnosticJson = report.diagnosticJson({ app, v1Extra, appLog: ring.snapshot() });
// 8. So the library's onboarding screens can send one too (coverage S5).
bravelyDiagnostics.setReportUploader(async ({ surface }) => uploadDiagnosticManual()); // resolves "DIAG-XXXXX"- Build exactly one
BravelyAccountManagerper page. The recorder follows the newest manager for the signed-in account andaccess_state, so a second one (a throwaway, or one per component) takes the report with it. - An app line after an account change stays in the report only when its
tsis readable and later thanreport.accountChangedBefore;mergedLogBufferapplies that rule, and the facade's own ring should too. - Mark the Settings "Help & Diagnostics" section with
REPORT_SURFACE_ATTRIBUTE(data-bravely-report-surface): focus inside it never counts as the working surface's, so a report sent from Settings still says what had focus before. - Catalog test (v2 §3.2): run every event the app sends through
filterBreadcrumb(name, props, mergeVocabulary(familyVocabulary(), APP_VOCAB))and assert nothing the app meant to keep comes out dropped orother. - The library's own "Send Diagnostic Report" action (
<DiagnosticReportAction>) sits in the footer of the account-aware<BravelySignInScreen>,<TrialChooser>,<OffersPage>,<TrialExpiryChooser>and<FinalPaywall>. It shows only while someone is signed in AND an uploader is registered, runs one upload at a time across every surface, and shows the §4.4 / §4.5 dialogs. The uploader must log the DIAG code at info and a failure at warn itself (bug-report-button-spec.md§4.8), because a screen that has gone opens nothing. An app screen can render it too:<DiagnosticReportAction surface="hold" style={style} />.
Changelog
0.7.3 - 2026-09-29
A small diagnostics patch (no host API change; hosts pin 0.7.3):
- Coalescing and error merging never join across a sign-out or an account switch: a breadcrumb or an error recorded while signed out belongs to whoever signs in next, so it never folds into the previous account's entry (and is no longer dropped with it when a different account signs in).
- The redactor passes the reference's 61 vectors: a fixed path registered in absolute form under the home folder is
kept in its
~form;RedactionOptionstakes the optionalfullNameandcomputerName(a browser knows neither), matched before the account and machine names; and R5 and R7 start at the ASCII boundary(?<![A-Za-z0-9_]), the patterns every port now carries. - Two more version vectors record as
other:1.0.0-divorce.pdfand2.0.0-katy.
0.7.2 - 2026-09-29
The independent verify's should-fixes for the diagnostics port (no host API change; hosts pin 0.7.2):
exception_typekeeps an error'snameonly when it is an identifier (^[A-Za-z_$][A-Za-z0-9_$]{0,127}$), elseother: any code can setname, and a path, a file name or a person's name used to ride through it.- After an account change,
mergedLogBufferkeeps an app line only when itstsis readable and later than the change: a line from the change's own millisecond, or one with no readable time, now goes. - Problem codes and the library's token mapping read only their tables' own keys (
code: "constructor"used to findObject). - The redaction tests run the reference's 50 vectors (three sign-in-name case variants were added).
- The README's "Diagnostics v2" section says which screens an app pushes itself, that Settings, Help and About push none, and to build one manager per page. The published comments name no internal paths, and the redactor's private-use slot characters are spelled as escapes.
- New tests pin what the verify found untested: the account rule through the real manager (sign in as A, act, sign
out, sign in as B; the report and the S5 action follow), the
ui_statedeny keys, the 8-token cap, the slot-marker sanitising and the time zone's sign.
0.7.1 - 2026-09-29
- The "Send Diagnostic Report" dialog balances its body line as well as its title, so "Share this code with support
so we can find your report:" never ends on one lone word (it left "report:" alone at 380 px). The dialog is now its
own presentational component (
DiagnosticReportDialog, internal), which a test renders to markup to prove it.
0.7.0 - 2026-09-29
bravelyDiagnostics: the v2 diagnostic report content (diagnostic-report-v2.md, §8.5 for web), the web port of bravely-account-msal 0.8.1'sBravelyDiagnostics. The breadcrumb filter with the 2026-09-29 version rule (at most 64 characters; a pre-release part of numbers and channel words only), the 200-entry ring with 60-second coalescing,ui_statewith its vocabulary checks and the §4.6 budget cut,focus_kindfromdocument.activeElement,env, the 50-entry errors ring with problem codes, the one redactor (R1-R8), the §6 assembly helpers and the §3.5 account rule. See "Diagnostics v2" above for the wiring.- The journey records every step in the report's trail (
src: lib) before its own consent and transport gates, and records the library's own non-token wire values as the tokens every port reports (slot: "GSO"→gso,access_state: "trialActive"→trial_active,kind: "crossDevice"→cross_device). The wire itself is unchanged.JourneyEmitterConfig.diagnosticsswaps the recorder (tests; null records nothing). BravelyAccountManagerattaches itself as the account facade the report follows, so an app wires neither the signed-in account noraccess_state.- "Send Diagnostic Report" on the library's onboarding screens (coverage S5):
<DiagnosticReportAction>in the footer of the account-aware sign-in screen, Trial Options, the offers page, the expiry chooser and the final wall, whenever someone is signed in and the app registeredbravelyDiagnostics.setReportUploader(…).AccountFootergains adiagnosticActionslot; the sign-in footer wraps when it holds three links. - Additive: nothing existing changed shape, and an app that wires nothing sees no difference on screen.
0.6.11 - 2026-09-15
- The guided-step dim is now pure black at 0.42 opacity on a light host, 0.62 on a dark host — replacing the old
violet-tinted
rgba(24, 18, 30, 0.55)/rgba(6, 3, 15, 0.74), which read as an oddly grey background on a light card (#1373). Every place a guided step dims (the spotlight's cutout and the anchorless full-scrim pane) reads the same token, so they can't drift apart. A hostpalette.scrimoverride still wins. <GuidedStepOverlay>never traps on a step whose anchor did not resolve: the full scrim was alreadypointer-events: noneand the card alreadypointer-events: auto, so a click always reached the app underneath. It now also logs oneconsole.warnnaming the step, so a host's anchor that fails to resolve shows up in the console instead of silently dimming with no spotlight and no trace.emitAppFirstOpenedIfNeeded()'s fire-once guard is now keyed by the store an adapter reports (Storage.getStoreId) — the same identitygetOrMintInstallId()already uses — instead of the adapter object itself. Two adapters built over the same origin store (the manager, a journey, a flow, a host's own no-argument call) now share one fire instead of each finding no sentinel and both emittingapp_first_opened. Flagged as an open item in 0.6.10; fixed here.
0.6.10 - 2026-09-15
- Per-account onboarding-experience progress (
onboarding_experience_state:<accountId>, #1382) is now stored beside the installation's ownonboarding_staterow: IndexedDB, or localStorage where the browser has no IndexedDB. 0.6.9 put it in the tab's sessionStorage, so a signed-in customer's progress ended with the tab and never reached a second tab, and the one-time migration moved an existing customer's completed state out of the installation row into that tab. The migration now writes the adopted row before it strips the installation row, and a strip that fails no longer discards the adoption. A row 0.6.9 left in the current tab's sessionStorage moves to the installation tier the first time it is read, andremove()drops it too. Once that tab has closed the progress is gone: nothing is inferred fromappLandedAt, which belongs to the installation, not to an account. - One installation id per installation.
getOrMintInstallId()shared a mint only between calls on the same adapter object, while the manager, a journey, the onboarding flow and a host's no-argument call each build their own default storage, so one fresh load could mint two ids milliseconds apart and the journey outbox then dropped the first id's rows as another installation's. Mints are now shared by the store an adapter reports (the new optionalStorage.getStoreId(key), reported by every built-in adapter and forwarded by the manager's wrapper), run under a cross-tab lock, and adopt what storage holds once the write has landed. A storage refusal or a read that never answers still rejects without minting. - Hosts change nothing but the pin. No wire field changed. A custom adapter that wraps another can forward
getStoreIdso its calls join the same mint; one that does not still mints under the lock.
0.6.9 - 2026-09-15
- The guided-step spotlight's hole and ring now follow the anchor's own shape
(#1373) instead of always cutting a rectangle:
<GuidedStepOverlay>draws both as ONE element's box-shadow, so a round or pill control never keeps square scrim corners over it.useAnchorRectmeasures the anchor's own corner radius andAnchorRectcarries it (optionalradius, CSS px); a control that reports none keeps the existing 12px default. The scrim's dim itself is unchanged. - Onboarding-experience progress (current step, per-step state, the completed
flag) is now scoped per ACCOUNT, not per install (#1382): pass
accountIdtoOnboardingFlow(a string, ornullfor signed out) and callsetAccountId()when the signed-in account changes without a fresh page load. Signing out of one account and into another on the same install — or, since this library shares storage across tabs, a second tab signed into a different account — no longer shows one account another's progress or lets it skip the experience. A pre-existing per-install completed flag is adopted by the first account that signs in after the update, and by no account after that. A host that never suppliesaccountIdkeeps the exact pre-0.6.9 behavior;appLandedAtand the landing claim stay per install either way. /api/entitlements's additiveactive_entitlement_detailsis decoded when the router sends it, matched ontoactive_entitlementsbylookup_key:will_renew,expires_at,product_idandstorenow reflect what the router actually knows about a key instead of the previous fabricatedexpires_at: null, will_renew: false, store: nullfor every key. When the array is absent, not an array, or a key isn't in it, that key keeps exactly the old fabricated default — never a guessed one.- Dev-dependency bump only (all six alerts were
scope=development; this package ships no runtime dependencies):vitest/@vitest/coverage-v8to^4.1.11, and an override pinning the nestednanoid/postcssvitepulls in to^3.3.19/^8.5.28.
0.6.8 - 2026-09-13
- Hold unread session/cache storage and refresh-only offline sessions. Add
session_unavailable, optionalsessionUnavailablerouting input and the retryable activation outcomeunavailable. While held nothing unlocks, umbrella keys included. - A page that confirmed its session and its access live keeps both when storage stops answering (lib-contract §12). Only a cold page, or evidence past its age, holds; an earlier page's cache never defeats a hold, and evidence without a readable age counts as expired.
- Persist the session tuple atomically, ordered by a logical
generation, never a clock. Failed writes keep replacement credentials in memory and retry; a write that lost to a newer sign-in or sign-out is dropped on the next read, never served from memory. - Every operation binds its session generation and account before its first await. Refresh results, rejections, OAuth completions, trial or pass answers and requests that land after this page signed out, signed in or followed another account are dropped, and a 401 retry never re-binds to another account.
- A refresh token is reserved in durable storage (
session_refresh_reserved, a fingerprint) before it is spent. No other tab presents a reserved token, and the owner rotates its own unsaved replacement. signOut()resolvesSignOutResult({ storageCleared }) and never rejects for storage. It signs the page out before any await, writes a non-secret tombstone (session_signed_out:{ version: 2, id, ends }) under the session write lock, and deletes only what a read shows it ended.storageClearedis false until deferred cleanup finishes, and a reload stays signed out.- Every access-bearing read reconciles the durable session, so another tab's sign-in or sign-out is followed without a notice. Access is bound to the account the page serves.
restore()is single-flight per session, a repeated hold is not re-announced, and a page already serving its session keeps it on screen while it re-reads.- A callback whose PKCE record does not answer holds as
session_unavailableinstead of failing;signIn()on the same callback URL retries. - Preserve provisional/hold decisions in both convenience route helpers.
- Commit complete paid live access in memory before optional persistence.
- Bound browser storage opens/transactions, guard denied getters and expose
availability/durability through
StorageStatus(missingmarks an absent API) andgetStorageStatus(). A refusing legacy tab store is a non-answer, never an absent session, and a refused IndexedDB never mints a replacement installation id. - Journey: once-claims (
app_landed, first value) are queued with their row and committed together; claim and outbox locks are acquired within the storage bound and held until their writes settle; rows carry the account they were emitted under and never post with another account's bearer. An unavailable installation id holds steps in memory. Unread nudge history suppresses nudges;readLastNudgeAt()rejects a non-answer. openMoneySurface()spends the §11 bypass on every genuine open; the 10 s floor governs resumes.useActivationLaneFromUrl({ autoStartPolling })polls again after anunavailableoutcome (2s, 4s, 8s). The error firehose keeps its events when the installation id does not answer.- Preserve shared hydration/reset, intro defaults and the read-all cross-app
route. 908 tests / 51 files at 0.6.7, 971 / 59 at the first candidate, 1040 /
76 after the first review, 1101 / 88 after the second, 1146 / 102 after the third, 1164 / 109 after the fourth, 1188 / 118 after the fifth, 1225 / 124 after the sixth, 1258 / 130 after the seventh, 1287 / 132 after the eighth, 1298 / 135 after the ninth, 1314 / 137 after the tenth.
HANDOFF.mdhas the mutation evidence, the §12/§13 conformance and the W5 port note. - Round three: a sign-out ends every row of its session's lineage, including
another tab's later rotation, on every read and reload. A mark that could not
be read is never written over, and
storageClearedis true only when a read after the delete shows nothing it ended remains. - A refresh reservation records whether its owner is alive (a lifetime Web Lock,
or a lease without one) and a durable sent mark. A dead owner's unsent
reservation is taken over; a sent one is never presented again, and once its
access token is refused the session ends to sign-in with
signed_out.reason: "reconnect". A page's own unsaved replacement is reserved before it is presented. - Trial, extension, pass, checkout, billing portal and download-email calls act
only for the account the page shows. A move meanwhile posts nothing and returns
stale(account_changedfrom checkout and the portal). - Every request is bounded (30 s, headers and body) and composed with the caller's signal. Entitlement flights are keyed by bearer. A session write holds its lock at most 4 s, and a write that lands late is never adopted.
- A trial answer never confirms or re-ages cached keys; signed-in state passes the getters' filter; an activation tick that restores sends no second request; sign-out deletes the shared cache row only when it is the ended account's.
getAccessState()reconciles with storage that answers synchronously (Storage.getSync?()). NewsessionEverStored()answers whether a session was ever stored in this browser, through the session row and the tombstone.- Round four: an owner whose refresh answer was lost resolves its own sent reservation while its page lives (backoff 2 s to 60 s, the online event, and a hint that another tab past the 60 s lease sends by BroadcastChannel and a mark on the reservation). Ten minutes after the first sent mark the session ends to the reconnect prompt in every tab and on a reload, never by presenting the token.
- A 0.6.8 page that rotates a session read from the legacy per-key rows deletes the legacy rows still holding the replaced tokens, so a 0.6.6/0.6.7 tab left open signs itself out instead of presenting the spent token and revoking the family.
- The owner Web Lock is held only while a page owns a refresh reservation.
- A session write is aborted when its 4 s hold ends; the default IndexedDB adapter
honours the signal (
Storage.set/removetake optionalStorageWriteOptions). getAppDataToken()mints only for the account the page shows.BAS_STORAGE_KEYis deprecated: a raw read returnsnullfor 0.6.8 sign-ins; usesessionEverStored()and the asynchronous reads. The export stays in 0.6.8.- Round five: no session ever ends on the clock alone. An owner keeps resolving its own sent refresh token until the server answers definitively, a confirmed replacement is served from memory until its save lands, and other tabs end only when their own access token is refused past the 10-minute bound.
- A session row that will never parse (garbage, or a newer library's row) answers
signed_outwithreason: "reconnect"at launch, and a sign-in sets it aside intosession_unreadablebefore persisting; refused reads still hold. - A sent refresh reservation is kept through a 429 or a failed mark on a retry; a rejected retry of a sent token ends to the reconnect prompt.
- The refresh attempt lock covers only reserve, mark and release, never the request; an episode ended by a session change releases the owner lock; the legacy cleanup is recorded in the rotation row and survives a reload.
- Error telemetry consent is granted only on exactly
"true"; an unreadable or unknown value, or a denied localStorage, suspends collection and writes no default. - Round six: a Journey outbox row that will never parse (garbage, or a newer
library's shape) is set aside into a bounded
journey_quarantineunder the outbox lock,
