@bravely-studios/account-web
v0.5.0
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.
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.
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. |
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("mailto:[email protected]")}
/>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
// Cross-app filter — excludes <currentApp>_pro, includes bravely_premium.
const others = manager.crossAppEntitlements();
// 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"
// 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— the durable session (bas,ba_id,email,expires_at,refresh_token), entitlement cache, DPoP key handle, and the install metrics keys. Survives tab close, browser restart, and app updates (D4 session-forever). Key bytes never leave the browser (extractable=false). Pre-0.4.0 sessionStorage session rows migrate to IndexedDB on first read.- In-memory — SSR / test fallback.
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.
Changelog
0.4.1 — 2026-06-11
D3 v1.2 patch (Jeff field feedback 2026-06-11; plan §4):
- Per-step sizing (a):
<BravelySignInScreen>renders COMPACT — the column caps atSIGN_IN_COMPACT_MAX_WIDTH(480px) and self-centers; sign-in never inherits offer geometry.computeOfferFitis now HEIGHT-FIRST: the scale fillsviewportHeight × 0.92; width binds only as a cap atviewportWidth × 0.95(widthCapBoundreports when it does). The single-arg signature is unchanged (it IS the offer-step formula); thepadfield +OFFER_FIT_PADare deprecated and ignored — breathing room now comes from the fractions (OFFER_FIT_HEIGHT_FRACTION/OFFER_FIT_WIDTH_CAP_FRACTION). - Slot-card baseline alignment (b): in the 3-up layout the cards share the parent's row tracks via CSS subgrid — every section row (name/tagline/price/price-sub/bullets/guarantee/bonus/nudge/CTA) equalizes to its tallest sibling; a missing section (the LTV "/mo", the HOOK bonus, the RVA HOOK guarantee) leaves its track empty instead of pulling content up; the CTA pins to the shared bottom row. Non-subgrid browsers gracefully keep the flex-column card; stacked mode unchanged.
- Locked copy (c): bonus headlines are now
FREE full access to [the 8 other premium utilities] — 9 apps in all(GSO) /FREE [lifetime] access to [the 8 other premium utilities] — 9 apps in all(LTV; accentlifetimespan kept); slot-1 nudge isWant the other 8 apps free? Go Annual →. The "all 9 apps, every platform, every device, + the bonus bundle" tagline STAYS (total count is correct). Byte-identical with the Swift/C#/get.bravely.dev surfaces.
0.4.0 — 2026-06-11
Login-first cutover W1 (plan §§2, 4, 5, 7 — D1/D3/D4/D6):
- Session forever (D4):
bas/ba_id/emailmoved from sessionStorage to IndexedDB (PKCE verifier/state stay session-scoped; legacy rows migrate on first read);expires_atpersisted from every token response; thegrant_type=refresh_tokenrotation loop implemented against{authority}/oauth/token(router ≥1.6.0) with single-flight dedupe; pre-expiry background refresh (48h window) onrestore(); 401 → refresh-then-retry-once on every BAS-authed call; session resurrection from a durable refresh token when the BAS row is missing; destructive sign-out ONLY on definitiveinvalid_grant/ 401-after-refresh — transport/5xx/429 failures keep every token. <BravelySignInScreen>(D1): the canonical login-first first screen.<OfferSlotGrid>(D3): the locked 3-slot offer grid + copy factory, per-app themes, viewport fit with scale-up.- Install metrics (D6):
getOrMintInstallId()+emitAppFirstOpenedIfNeeded(). - Activation machine v1.1.0:
user_skippedremoved in lockstep withbravely-commerce-router(legacy event strings are a no-op). - Types:
CheckoutPlangains"onetime"(interval-true IVA Slot-1 token per the 2026-06-10 catalog rename); API baseline note → 1.6.0. - Fix: stale "magic-link only" comment on the email provider button (password is primary per ADR 0014).
0.3.10 — 2026-06-05
- Feature: optional
install_idpassthrough for PHASE-2 install→account reconcile.ManagerConfiggains an optionalinstallId; the manager exposessetInstallId()/getInstallId(), and threads the value onto both legs of sign-in — the/oauth/authorizequery (persisted to the app-auth code) and the/oauth/tokenexchange body. The wire field name isinstall_id. Strictly additive: with noinstall_id, the authorize URL and token body are byte-identical to prior releases (asserted by an equality test). Server-side persistence/reconcile already live in the commerce router; this release only emits the field.
0.3.9 — 2026-06-03
- Feature: added an optional host-injected
logseam toManagerConfig((event, ctx) => void). When supplied,BravelyAccountManageremits SECRET-FREE breadcrumbs (HTTP status + server{error}code + step) at the previously-silent failure paths —signOutrevoke,pollForActivation(HTTP + network errors), andrefreshEntitlements(401 / non-2xx / network) — so the host's diagnostic ring buffer can capture the real cause. The four prior hardcodedconsole.infolines (DPoP unavailable, deprecation hints) now route through this seam too. Omittinglogis a no-op (no behavior change). The seam is also threaded intoEntitlementCache(previously unreachable) so itsoffline_entitlement_servedmarker reaches the host sink. Breadcrumbs never carry tokens, passwords, OAuth codes, full emails, or PII. (Logging Wave 4.)
0.3.5 — 2026-05-14
- Feature: added
<BravelyProviderButtons>— the canonical 3-button provider picker (Apple / Google / Email) used across the Bravely web family. Mirrors the SwiftUI surface inbravely-account-swiftso the iOS/Mac/Web variants of the same utility share visual treatment. Pure React + inline SVGs; no runtime dependency, no CSS file. - Types: exported
BravelyProviderButtonsProps,BravelyProviderButtonStyle, andProviderHint.
0.3.4 — 2026-05-13
- Infra: package now publishes to
registry.npmjs.org(public scope). Previously hosted on GitHub Packages. No code changes; behavior is identical to0.3.3.
0.3.3 — 2026-05-13
- Feature:
BravelyAccountManagerentersfresh_launch_restorationwhen a BAS exists but the entitlement cache is missing, smoothing the cold-start UX before the first entitlement read completes.
0.3.2 — 2026-05-13
- Fix: BAS-authenticated manager requests now attach an RFC 9449
DPoPproof header, includingath, while preserving the Gate 1Authorization: Bearer <bas>scheme required by current router endpoints. - Fix: fresh entitlement cache writes carry the generated DPoP JKT thumbprint so Gate 2 cache binding can inspect the local key identity.
0.3.0 — 2026-05-13
- Feature: added
createPaddlePortalSession(), a BAS-authed manager primitive forPOST /api/paddle-portal. It returns the hosted Paddle customer-portal URL DTO and leaves checkout activation behavior unchanged. - Types: exported
PaddlePortalSessionand aligned the API baseline note tobravely-commerce-routerOpenAPI1.2.1.
0.2.1 — 2026-05-12
- Fix:
getState()now returns a stable reference between writes. Previously it cloned on every read, which broke React 19'suseSyncExternalStore(snapshot identity changed every render → React error #185 / "Maximum update depth exceeded"). The clone now happens once, insidesetState(), before listeners fire. Listener payloads and the nextgetState()call return the same object reference. Three consumers (prodjectly,printscreenly-web,todoingly-web) carry module-level_cachedStateworkarounds that become redundant with this release — they can be dropped in a follow-up sweep. - Fix: internal
libVersiondefault aligned to package version (was hard-coded to"0.2.0"). - Docs: install snippet uses the correct
@bravely-studiosscope.
0.2.0 — 2026-05-11
- Initial C-ux M2/M3/M4 facade shipped.
License
Proprietary. (c) 2026 Bravely Studios LLC.
