@bravely-studios/account-web 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +134 -5
  2. package/dist/ActivationStateMachine.d.ts +1 -1
  3. package/dist/ActivationStateMachine.d.ts.map +1 -1
  4. package/dist/ActivationStateMachine.js +6 -2
  5. package/dist/ActivationStateMachine.js.map +1 -1
  6. package/dist/BravelyAccountManager.d.ts +132 -2
  7. package/dist/BravelyAccountManager.d.ts.map +1 -1
  8. package/dist/BravelyAccountManager.js +429 -30
  9. package/dist/BravelyAccountManager.js.map +1 -1
  10. package/dist/components/BravelyProviderButtons.d.ts.map +1 -1
  11. package/dist/components/BravelyProviderButtons.js.map +1 -1
  12. package/dist/components/BravelySignInScreen.d.ts +55 -0
  13. package/dist/components/BravelySignInScreen.d.ts.map +1 -0
  14. package/dist/components/BravelySignInScreen.js +141 -0
  15. package/dist/components/BravelySignInScreen.js.map +1 -0
  16. package/dist/components/OfferSlotGrid.d.ts +204 -0
  17. package/dist/components/OfferSlotGrid.d.ts.map +1 -0
  18. package/dist/components/OfferSlotGrid.js +490 -0
  19. package/dist/components/OfferSlotGrid.js.map +1 -0
  20. package/dist/index.d.ts +5 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/installMetrics.d.ts +40 -0
  25. package/dist/installMetrics.d.ts.map +1 -0
  26. package/dist/installMetrics.js +128 -0
  27. package/dist/installMetrics.js.map +1 -0
  28. package/dist/oauth.d.ts +18 -0
  29. package/dist/oauth.d.ts.map +1 -1
  30. package/dist/oauth.js +3 -0
  31. package/dist/oauth.js.map +1 -1
  32. package/dist/storage.d.ts +23 -10
  33. package/dist/storage.d.ts.map +1 -1
  34. package/dist/storage.js +52 -16
  35. package/dist/storage.js.map +1 -1
  36. package/dist/types.d.ts +23 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/types.js +2 -1
  39. package/dist/types.js.map +1 -1
  40. package/package.json +1 -1
@@ -20,9 +20,18 @@ const KEY_BAS = "bas";
20
20
  const KEY_REFRESH = "refresh_token";
21
21
  const KEY_BA_ID = "ba_id";
22
22
  const KEY_EMAIL = "email";
23
+ const KEY_EXPIRES_AT = "expires_at";
23
24
  const KEY_DPOP_JKT = "dpop_jkt_thumbprint";
24
25
  const KEY_PKCE_VERIFIER = "pkce_verifier";
25
26
  const KEY_PKCE_STATE = "pkce_state";
27
+ /**
28
+ * Proactive refresh window (D4): when the BAS has less than this much life
29
+ * left (or is already past `expires_at`), the manager rotates the session in
30
+ * the background via `grant_type=refresh_token` instead of waiting for a 401.
31
+ * BAS access tokens live 14 days; 48h is comfortably inside that while still
32
+ * rotating well before expiry for any returning visitor.
33
+ */
34
+ export const PRE_EXPIRY_REFRESH_WINDOW_MS = 48 * 60 * 60 * 1000;
26
35
  export class BravelyAccountManager {
27
36
  cfg;
28
37
  storage;
@@ -40,6 +49,23 @@ export class BravelyAccountManager {
40
49
  libVersionPolicy = null;
41
50
  dpopKeypairPromise = null;
42
51
  dpopJktThumbprint = null;
52
+ /**
53
+ * PHASE-2 install→account reconciliation id (optional). When set, it is
54
+ * forwarded as `install_id` on BOTH the OAuth authorize URL and the
55
+ * `/oauth/token` exchange so the auth server can reconcile the install onto
56
+ * the resolved `ba_id`. `null` ⇒ nothing is sent and both legs stay
57
+ * byte-identical to the pre-`install_id` flow. Settable post-construction
58
+ * via `setInstallId()` because the host often learns the id asynchronously
59
+ * (e.g. after reading it from native bridge / first-launch storage).
60
+ */
61
+ installId = null;
62
+ /**
63
+ * Single-flight guard for the refresh grant: concurrent 401s / proactive
64
+ * refreshes join the in-flight rotation instead of racing the one-time-use
65
+ * refresh token (a second concurrent presentation of the same token would
66
+ * trip the server's family reuse detection and revoke the session).
67
+ */
68
+ refreshInFlight = null;
43
69
  constructor(config) {
44
70
  if (!config.authority)
45
71
  throw new Error("authority is required");
@@ -59,9 +85,13 @@ export class BravelyAccountManager {
59
85
  redirectUri,
60
86
  scope: config.scope ?? DEFAULT_SCOPE,
61
87
  libName: config.libName ?? "bravely-account-web",
62
- libVersion: config.libVersion ?? "0.3.9",
88
+ libVersion: config.libVersion ?? "0.4.0",
63
89
  };
64
90
  this.storage = config.storage ?? defaultStorage();
91
+ // Optional PHASE-2 install→account reconciliation id. When the host omits
92
+ // it (the common case), `installId` stays null and no `install_id` rides
93
+ // on the authorize URL or token exchange — byte-identical legacy behavior.
94
+ this.installId = normalizeInstallId(config.installId);
65
95
  // No-op when the host injects nothing — preserves prior silent behavior.
66
96
  this.log = config.log ?? (() => undefined);
67
97
  // Thread the same seam into the entitlement cache so its
@@ -101,10 +131,38 @@ export class BravelyAccountManager {
101
131
  getLibVersionPolicy() {
102
132
  return this.libVersionPolicy ? { ...this.libVersionPolicy } : null;
103
133
  }
134
+ /**
135
+ * Set (or clear) the PHASE-2 install→account reconciliation id. Idempotent.
136
+ * Pass the per-install identifier the native client minted at
137
+ * `app_first_opened`; the manager forwards it as `install_id` on the next
138
+ * OAuth authorize redirect AND on the token exchange, so the auth server's
139
+ * existing reconcile path can stamp the resolved `ba_id` onto that install.
140
+ *
141
+ * Pass `undefined`/`null`/empty to clear it — after which the flow is again
142
+ * byte-identical to the pre-`install_id` behavior (no param sent). Purely
143
+ * additive: existing consumers that never call this send no `install_id`.
144
+ */
145
+ setInstallId(installId) {
146
+ this.installId = normalizeInstallId(installId);
147
+ }
148
+ /** Current install→account reconciliation id, or `null` if none is set. */
149
+ getInstallId() {
150
+ return this.installId;
151
+ }
104
152
  /**
105
153
  * Hydrate from persistent storage and (if a BAS is present) verify it
106
154
  * against the identity API. Call once on page load before rendering UI
107
155
  * that depends on `getState()`.
156
+ *
157
+ * D4 session-forever behavior:
158
+ * - A stored BAS yields `signed_in` immediately (cache-backed); the
159
+ * entitlement check runs in the background. Transport failures NEVER
160
+ * wipe tokens or flip the state to signed_out.
161
+ * - A BAS near/past `expires_at` triggers a background refresh-grant
162
+ * rotation (`PRE_EXPIRY_REFRESH_WINDOW_MS`).
163
+ * - No BAS but a stored refresh token → the session is resurrected via
164
+ * `grant_type=refresh_token` before giving up (sessions survive even
165
+ * if the access token row was lost).
108
166
  */
109
167
  async restore() {
110
168
  await this.cache.hydrate();
@@ -112,6 +170,25 @@ export class BravelyAccountManager {
112
170
  const baId = await this.storage.get(KEY_BA_ID);
113
171
  const email = await this.storage.get(KEY_EMAIL);
114
172
  if (!bas || !baId || !email) {
173
+ // No usable BAS triple — but a durable refresh token can still
174
+ // resurrect the session without any user interaction.
175
+ const refresh = await this.storage.get(KEY_REFRESH);
176
+ if (refresh) {
177
+ this.activation.transition("app_launched", { bas_present: true });
178
+ const outcome = await this.refreshSession();
179
+ if (outcome === "refreshed") {
180
+ // persistRefreshedToken set the signed_in state; run the
181
+ // authoritative entitlement check in the background.
182
+ void this.refreshEntitlements().catch(() => undefined);
183
+ return this.getState();
184
+ }
185
+ // definitive_failure already cleared storage; transient failures
186
+ // KEEP the refresh token for the next launch (never wipe on
187
+ // transport failure) — but without a BAS there is no session to
188
+ // serve, so surface signed_out + pending_user_action.
189
+ this.activation.transition("bas_invalid");
190
+ return this.setState({ kind: "signed_out" });
191
+ }
115
192
  this.activation.transition("app_launched_no_session", { bas_present: false });
116
193
  return this.setState({ kind: "signed_out" });
117
194
  }
@@ -128,6 +205,12 @@ export class BravelyAccountManager {
128
205
  this.activation.force("fresh_launch_restoration");
129
206
  }
130
207
  this.setState({ kind: "signed_in", ba_id: baId, email, bas, entitlements: cachedEntitlements });
208
+ // Pre-expiry rotation: refresh in the background when the BAS is inside
209
+ // the proactive window (or already expired). Single-flight, so the
210
+ // entitlement refresh below joining a 401-triggered rotation is safe.
211
+ if (await this.shouldProactivelyRefresh()) {
212
+ void this.refreshSession().catch(() => undefined);
213
+ }
131
214
  // Refresh in the background; failures fall back to cache.
132
215
  void this.refreshEntitlements().catch(() => {
133
216
  // Already handled by transition logic; nothing else to do.
@@ -169,7 +252,8 @@ export class BravelyAccountManager {
169
252
  return this.setState({ kind: "failed", error: errorMessage(err) });
170
253
  }
171
254
  }
172
- // Otherwise, kick off the redirect.
255
+ // Otherwise, kick off the redirect. `installId` rides along only when the
256
+ // host has set one (PHASE-2 reconcile); absent ⇒ legacy authorize URL.
173
257
  const { verifier, state, authorizeUrl } = await preparePkce({
174
258
  authority: this.cfg.authority,
175
259
  clientId: this.cfg.appSlug,
@@ -177,6 +261,7 @@ export class BravelyAccountManager {
177
261
  scope: this.cfg.scope,
178
262
  loginHint: opts.loginHint,
179
263
  provider: opts.provider,
264
+ installId: this.installId ?? undefined,
180
265
  });
181
266
  await this.storage.set(KEY_PKCE_VERIFIER, verifier);
182
267
  await this.storage.set(KEY_PKCE_STATE, state);
@@ -189,6 +274,7 @@ export class BravelyAccountManager {
189
274
  async signOut() {
190
275
  try {
191
276
  const bas = await this.storage.get(KEY_BAS);
277
+ const refresh = await this.storage.get(KEY_REFRESH);
192
278
  if (bas) {
193
279
  // Fire-and-forget revoke; if the OAuth server doesn't have a revoke
194
280
  // endpoint yet (Gate 1), this is a no-op. Wrapped in try/catch so
@@ -196,7 +282,11 @@ export class BravelyAccountManager {
196
282
  await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/revoke`, {
197
283
  method: "POST",
198
284
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
199
- body: new URLSearchParams({ token: bas, client_id: this.cfg.appSlug }).toString(),
285
+ body: new URLSearchParams({
286
+ token: bas,
287
+ token_type_hint: "access_token",
288
+ client_id: this.cfg.appSlug,
289
+ }).toString(),
200
290
  }).catch((err) => {
201
291
  // Best-effort revoke; local sign-out proceeds regardless. Breadcrumb
202
292
  // the cause (never the token) so a server-side revoke regression is
@@ -204,19 +294,50 @@ export class BravelyAccountManager {
204
294
  this.log("signout_revoke_failed", { step: "oauth_revoke", error: errorMessage(err) });
205
295
  });
206
296
  }
297
+ // With sliding-365d refresh families (D4), explicit sign-out should
298
+ // also revoke the durable refresh family — RFC 7009 form, best-effort
299
+ // (the router's revocation endpoint is still pending; sending now means
300
+ // already-shipped libs revoke correctly the day it lands).
301
+ if (refresh) {
302
+ await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/revoke`, {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
305
+ body: new URLSearchParams({
306
+ token: refresh,
307
+ token_type_hint: "refresh_token",
308
+ client_id: this.cfg.appSlug,
309
+ }).toString(),
310
+ }).catch((err) => {
311
+ this.log("signout_revoke_failed", {
312
+ step: "oauth_revoke_refresh",
313
+ error: errorMessage(err),
314
+ });
315
+ });
316
+ }
207
317
  }
208
318
  finally {
209
- await this.storage.remove(KEY_BAS);
210
- await this.storage.remove(KEY_REFRESH);
211
- await this.storage.remove(KEY_BA_ID);
212
- await this.storage.remove(KEY_EMAIL);
213
- await this.storage.remove(KEY_DPOP_JKT);
214
- await this.cache.invalidate();
215
- this.dpopKeypairPromise = null;
216
- this.dpopJktThumbprint = null;
217
- this.setState({ kind: "signed_out" });
319
+ await this.clearLocalSession();
218
320
  }
219
321
  }
322
+ /**
323
+ * Drop every locally-persisted session artifact and flip to `signed_out`.
324
+ * The DESTRUCTIVE half of sign-out — only ever called from explicit user
325
+ * sign-out or a DEFINITIVE auth failure (`invalid_grant` on the refresh
326
+ * grant, or a 401 that survived a successful refresh-and-retry). Transport
327
+ * failures must never reach this.
328
+ */
329
+ async clearLocalSession() {
330
+ await this.storage.remove(KEY_BAS);
331
+ await this.storage.remove(KEY_REFRESH);
332
+ await this.storage.remove(KEY_BA_ID);
333
+ await this.storage.remove(KEY_EMAIL);
334
+ await this.storage.remove(KEY_EXPIRES_AT);
335
+ await this.storage.remove(KEY_DPOP_JKT);
336
+ await this.cache.invalidate();
337
+ this.dpopKeypairPromise = null;
338
+ this.dpopJktThumbprint = null;
339
+ this.setState({ kind: "signed_out" });
340
+ }
220
341
  /**
221
342
  * Read entitlements, serving the cached row if fresh and refreshing
222
343
  * opportunistically. Always returns an array (possibly empty).
@@ -319,8 +440,9 @@ export class BravelyAccountManager {
319
440
  * introduced in the Mac/iOS v2 onboarding port.
320
441
  *
321
442
  * Throws if the user is not signed in (`not_signed_in`) or if the server
322
- * returns a non-2xx status. 401 is surfaced as a sign-out (matching the
323
- * pattern in `refreshEntitlements`).
443
+ * returns a non-2xx status. A definitive 401 (refresh-and-retry exhausted)
444
+ * is surfaced as `not_signed_in`; a 401 riding a transient refresh failure
445
+ * keeps the session and throws a plain request failure.
324
446
  */
325
447
  async getAccountEntitlements() {
326
448
  const bas = await this.requireBas();
@@ -328,8 +450,12 @@ export class BravelyAccountManager {
328
450
  method: "GET",
329
451
  });
330
452
  if (res.status === 401) {
331
- await this.signOut();
332
- throw new Error("not_signed_in");
453
+ // `fetchWithBas` already refreshed-and-retried; a surviving 401 either
454
+ // cleared the session (definitive ⇒ not_signed_in) or rode a transient
455
+ // refresh failure (session kept ⇒ plain request failure).
456
+ if (await this.sessionWasCleared())
457
+ throw new Error("not_signed_in");
458
+ throw new Error("getAccountEntitlements failed: 401");
333
459
  }
334
460
  if (!res.ok) {
335
461
  throw new Error(`getAccountEntitlements failed: ${res.status}`);
@@ -379,7 +505,11 @@ export class BravelyAccountManager {
379
505
  try {
380
506
  const url = new URL("/api/entitlements", this.cfg.identityOrigin);
381
507
  url.searchParams.set("app", this.cfg.appSlug);
382
- const res = await this.fetchWithBas(url.toString(), bas, {
508
+ // Re-read the BAS each attempt: a mid-poll refresh rotation (401
509
+ // refresh-then-retry inside fetchWithBas) replaces the stored token,
510
+ // and polling on with the stale capture would 401 every round.
511
+ const currentBas = (await this.storage.get(KEY_BAS)) ?? bas;
512
+ const res = await this.fetchWithBas(url.toString(), currentBas, {
383
513
  method: "GET",
384
514
  });
385
515
  if (res.ok) {
@@ -455,6 +585,12 @@ export class BravelyAccountManager {
455
585
  code_verifier: verifier,
456
586
  client_id: this.cfg.appSlug,
457
587
  });
588
+ // PHASE-2 reconcile: carry the install id on the exchange too, so the
589
+ // server reconciles even if it keys off the token-leg value rather than
590
+ // (or in addition to) the value persisted from the authorize leg. Only
591
+ // appended when set — absent ⇒ the body is byte-identical to legacy.
592
+ if (this.installId)
593
+ body.set("install_id", this.installId);
458
594
  const res = await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/token`, {
459
595
  method: "POST",
460
596
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -474,7 +610,12 @@ export class BravelyAccountManager {
474
610
  await this.persistToken(data);
475
611
  return this.getState();
476
612
  }
477
- async persistToken(data) {
613
+ /**
614
+ * Persist the token + identity rows shared by the sign-in and refresh
615
+ * legs (BAS, rotated refresh token, ba_id, email, expires_at, lib version
616
+ * policy). Returns the inline entitlement snapshot.
617
+ */
618
+ async persistSessionCore(data) {
478
619
  const bas = "access_token" in data ? data.access_token : data.bas;
479
620
  const refresh = data.refresh_token ?? null;
480
621
  await this.storage.set(KEY_BAS, bas);
@@ -482,11 +623,19 @@ export class BravelyAccountManager {
482
623
  await this.storage.set(KEY_REFRESH, refresh);
483
624
  await this.storage.set(KEY_BA_ID, data.bravely_account_id);
484
625
  await this.storage.set(KEY_EMAIL, data.email);
626
+ // Track the BAS expiry client-side so the proactive refresh loop can
627
+ // rotate before a 401 ever happens (D4).
628
+ if (data.expires_at) {
629
+ await this.storage.set(KEY_EXPIRES_AT, data.expires_at);
630
+ }
485
631
  if ("bravely_lib_version" in data && data.bravely_lib_version) {
486
632
  this.libVersionPolicy = data.bravely_lib_version;
487
633
  }
634
+ return { bas, entitlements: (data.active_entitlements ?? []) };
635
+ }
636
+ async persistToken(data) {
637
+ const { bas, entitlements } = await this.persistSessionCore(data);
488
638
  // Active entitlements ride along on Gate 1 BAS exchange (B6).
489
- const entitlements = (data.active_entitlements ?? []);
490
639
  await this.cache.update({ entitlements });
491
640
  this.setState({
492
641
  kind: "signed_in",
@@ -503,6 +652,172 @@ export class BravelyAccountManager {
503
652
  this.activation.transition("entitlement_check_success_none");
504
653
  }
505
654
  }
655
+ /**
656
+ * Persist a `grant_type=refresh_token` rotation result. Differs from the
657
+ * sign-in leg deliberately:
658
+ * - The inline entitlement snapshot is best-effort on the server (an RC
659
+ * hiccup yields `[]` so rotation never fails) — so an EMPTY snapshot
660
+ * must not downgrade the 72h offline cache mid-session. Non-empty
661
+ * snapshots update cache + state; empty ones leave the authoritative
662
+ * `/api/entitlements` check (which runs on every restore) in charge.
663
+ * - No `entitlement_check_*` activation events are fired here for the
664
+ * same reason; only `bas_found` advances the restore lane.
665
+ */
666
+ async persistRefreshedToken(data) {
667
+ const { bas, entitlements } = await this.persistSessionCore(data);
668
+ // Contract enforcement (router ≥1.6.0 always rotates): if a 200 refresh
669
+ // response somehow lacks the rotated token, the OLD token is spent
670
+ // server-side — presenting it again later would read as reuse and revoke
671
+ // the family. Drop it: the fresh BAS still serves its full window, and
672
+ // the next 401 walks the no_refresh_token lane instead of a false-reuse
673
+ // revoke.
674
+ if (!data.refresh_token) {
675
+ await this.storage.remove(KEY_REFRESH);
676
+ this.log("session_refresh_missing_rotated_token", { step: "oauth_refresh" });
677
+ }
678
+ if (entitlements.length > 0) {
679
+ await this.cache.update({ entitlements, dpopJktThumbprint: this.dpopJktThumbprint });
680
+ }
681
+ const effective = entitlements.length > 0
682
+ ? entitlements
683
+ : this.state.kind === "signed_in"
684
+ ? this.state.entitlements
685
+ : (this.cache.raw()?.entitlements ?? []);
686
+ this.setState({
687
+ kind: "signed_in",
688
+ ba_id: data.bravely_account_id,
689
+ email: data.email,
690
+ bas,
691
+ entitlements: effective,
692
+ });
693
+ this.activation.transition("bas_found");
694
+ }
695
+ /**
696
+ * True when the stored BAS is inside the proactive-refresh window (or past
697
+ * expiry). Unknown expiry (legacy rows) ⇒ false — the 401-retry path
698
+ * covers those sessions.
699
+ */
700
+ async shouldProactivelyRefresh() {
701
+ const expiresAt = await this.storage.get(KEY_EXPIRES_AT);
702
+ if (!expiresAt)
703
+ return false;
704
+ const parsed = Date.parse(expiresAt);
705
+ if (Number.isNaN(parsed))
706
+ return false;
707
+ return parsed - Date.now() < PRE_EXPIRY_REFRESH_WINDOW_MS;
708
+ }
709
+ /**
710
+ * Rotate the session via `POST {authority}/oauth/token` with
711
+ * `grant_type=refresh_token` (router ≥1.6.0). Single-flight: concurrent
712
+ * callers join the in-flight rotation (the refresh token is one-time-use;
713
+ * racing it would trip server-side reuse detection).
714
+ *
715
+ * Cross-TAB races are handled by two layers: (a) where available, a Web
716
+ * Lock (`bravely:session_refresh:<slug>`) serializes rotations across tabs
717
+ * sharing the same IndexedDB token; (b) the token is re-read inside the
718
+ * critical section, and if another tab already rotated (stored BAS changed
719
+ * while we waited) the rotation is skipped. Browsers without Web Locks
720
+ * fall back to the server's 30s idempotent-retry grace for near-
721
+ * simultaneous presentations of the same token.
722
+ *
723
+ * Destructive ONLY on a definitive rejection (`invalid_grant` / 401):
724
+ * transport failures, 5xx, and 429 keep every token in place.
725
+ */
726
+ refreshSession() {
727
+ if (!this.refreshInFlight) {
728
+ this.refreshInFlight = this.refreshSessionCrossTab().finally(() => {
729
+ this.refreshInFlight = null;
730
+ });
731
+ }
732
+ return this.refreshInFlight;
733
+ }
734
+ async refreshSessionCrossTab() {
735
+ const basBefore = await this.storage.get(KEY_BAS);
736
+ const run = async () => {
737
+ // Another tab may have rotated while we waited on the lock — adopt its
738
+ // result instead of spending a second rotation.
739
+ if (basBefore) {
740
+ const basNow = await this.storage.get(KEY_BAS);
741
+ if (basNow && basNow !== basBefore)
742
+ return "refreshed";
743
+ }
744
+ return this.doRefreshSession();
745
+ };
746
+ const locks = globalThis.navigator?.locks;
747
+ if (locks?.request) {
748
+ try {
749
+ return await locks.request(`bravely:session_refresh:${this.cfg.appSlug}`, run);
750
+ }
751
+ catch (error) {
752
+ // Lock machinery failure (not a refresh verdict) — fall through to
753
+ // the unlocked path rather than failing the rotation.
754
+ this.log("session_refresh_lock_error", {
755
+ step: "oauth_refresh",
756
+ error: errorMessage(error),
757
+ });
758
+ }
759
+ }
760
+ return run();
761
+ }
762
+ async doRefreshSession() {
763
+ const refresh = await this.storage.get(KEY_REFRESH);
764
+ if (!refresh)
765
+ return "no_refresh_token";
766
+ try {
767
+ const res = await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/token`, {
768
+ method: "POST",
769
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
770
+ body: new URLSearchParams({
771
+ grant_type: "refresh_token",
772
+ refresh_token: refresh,
773
+ client_id: this.cfg.appSlug,
774
+ }).toString(),
775
+ });
776
+ if (res.ok) {
777
+ const data = (await res.json());
778
+ await this.persistRefreshedToken(data);
779
+ this.log("session_refreshed", { step: "oauth_refresh" });
780
+ return "refreshed";
781
+ }
782
+ let err = {};
783
+ try {
784
+ err = (await res.json());
785
+ }
786
+ catch {
787
+ /* ignore non-JSON error bodies */
788
+ }
789
+ // `invalid_grant` covers invalid / revoked / reuse-detected tokens —
790
+ // the server's uniform rejection. 401 = invalid_client-class failures.
791
+ // Both are definitive: the session cannot be recovered.
792
+ if (err.error === "invalid_grant" || res.status === 401) {
793
+ this.log("session_refresh_rejected", {
794
+ step: "oauth_refresh",
795
+ status: res.status,
796
+ error: err.error ?? null,
797
+ });
798
+ await this.clearLocalSession();
799
+ return "definitive_failure";
800
+ }
801
+ // 429 / 5xx / anything else: server-side trouble, NOT a verdict on the
802
+ // token. Keep the session and retry later.
803
+ this.log("session_refresh_http_error", {
804
+ step: "oauth_refresh",
805
+ status: res.status,
806
+ error: err.error ?? null,
807
+ });
808
+ return "transient_failure";
809
+ }
810
+ catch (error) {
811
+ // Network failure (offline) or the 426 kill-switch. Neither is a
812
+ // verdict on the refresh token — tokens are NEVER wiped on transport
813
+ // failure (D4).
814
+ this.log("session_refresh_network_error", {
815
+ step: "oauth_refresh",
816
+ error: errorMessage(error),
817
+ });
818
+ return "transient_failure";
819
+ }
820
+ }
506
821
  async refreshEntitlements() {
507
822
  const bas = await this.storage.get(KEY_BAS);
508
823
  if (!bas)
@@ -514,13 +829,26 @@ export class BravelyAccountManager {
514
829
  method: "GET",
515
830
  });
516
831
  if (res.status === 401) {
517
- // BAS invalid; treat as signed-out.
518
- this.log("entitlement_refresh_unauthorized", {
832
+ // `fetchWithBas` already ran refresh-then-retry-once and cleared the
833
+ // session iff the failure was definitive. Only mirror that verdict —
834
+ // never sign out on a 401 that rode a transient refresh failure.
835
+ if (await this.sessionWasCleared()) {
836
+ this.log("entitlement_refresh_unauthorized", {
837
+ step: "refresh_entitlements",
838
+ status: 401,
839
+ });
840
+ return [];
841
+ }
842
+ const cached = this.cache.getCached();
843
+ this.log("entitlement_refresh_unauthorized_transient", {
519
844
  step: "refresh_entitlements",
520
845
  status: 401,
846
+ entitlement_cache_present: !!cached,
521
847
  });
522
- await this.signOut();
523
- return [];
848
+ this.activation.transition("entitlement_check_failure", {
849
+ entitlement_cache_present: !!cached,
850
+ });
851
+ return cached?.entitlements ?? [];
524
852
  }
525
853
  if (!res.ok) {
526
854
  const cached = this.cache.getCached();
@@ -569,15 +897,74 @@ export class BravelyAccountManager {
569
897
  * the current router accepts Bearer on shared BAS endpoints, but attach a
570
898
  * real RFC 9449 proof in the `DPoP` header so Gate 2 traffic is already
571
899
  * exercising proof generation.
900
+ *
901
+ * D4 401 handling — refresh-then-retry-once: a 401 triggers ONE refresh
902
+ * grant; on success the request is retried with the new BAS. Destructive
903
+ * sign-out happens ONLY when the failure is definitive:
904
+ * - the refresh grant itself was rejected (`invalid_grant`/401), or
905
+ * - there is no refresh token to present, or
906
+ * - the retried request STILL came back 401 after a successful refresh.
907
+ * A transient refresh failure (offline/5xx/429) returns the original 401
908
+ * response WITHOUT touching the stored session — callers must treat a 401
909
+ * with the session still present as a transient failure (serve cache).
572
910
  */
573
911
  async fetchWithBas(input, bas, init = {}) {
574
- const method = init.method ?? "GET";
575
- const headers = new Headers(init.headers);
576
- headers.set("Authorization", `Bearer ${bas}`);
577
- const proof = await this.dpopProof(method, input, bas);
578
- if (proof)
579
- headers.set("DPoP", proof);
580
- return this.fetchWithDeprecation(input, { ...init, method, headers });
912
+ const doFetch = async (token) => {
913
+ const method = init.method ?? "GET";
914
+ const headers = new Headers(init.headers);
915
+ headers.set("Authorization", `Bearer ${token}`);
916
+ const proof = await this.dpopProof(method, input, token);
917
+ if (proof)
918
+ headers.set("DPoP", proof);
919
+ return this.fetchWithDeprecation(input, { ...init, method, headers });
920
+ };
921
+ const res = await doFetch(bas);
922
+ if (res.status !== 401)
923
+ return res;
924
+ // Another caller may have already rotated the session while this request
925
+ // was in flight (the caller captured a now-stale BAS). Retry with the
926
+ // current stored token before spending a rotation.
927
+ const current = await this.storage.get(KEY_BAS);
928
+ if (current && current !== bas) {
929
+ const retriedWithCurrent = await doFetch(current);
930
+ if (retriedWithCurrent.status !== 401)
931
+ return retriedWithCurrent;
932
+ // Still 401 — fall through to a real refresh with the live token.
933
+ }
934
+ const outcome = await this.refreshSession();
935
+ if (outcome === "refreshed") {
936
+ const rotated = await this.storage.get(KEY_BAS);
937
+ if (!rotated)
938
+ return res; // defensive; persistRefreshedToken always writes it
939
+ const retried = await doFetch(rotated);
940
+ if (retried.status === 401) {
941
+ // Fresh BAS minted seconds ago and still rejected — definitive.
942
+ this.log("bas_unauthorized_after_refresh", { step: "fetch_with_bas", status: 401 });
943
+ await this.clearLocalSession();
944
+ }
945
+ return retried;
946
+ }
947
+ if (outcome === "no_refresh_token" || outcome === "definitive_failure") {
948
+ // 401 with no recovery path (definitive_failure already cleared
949
+ // storage inside the refresh; the no-token path clears here).
950
+ if (outcome === "no_refresh_token") {
951
+ this.log("bas_unauthorized_no_refresh_token", { step: "fetch_with_bas", status: 401 });
952
+ await this.clearLocalSession();
953
+ }
954
+ return res;
955
+ }
956
+ // transient_failure: keep the session; surface the original 401.
957
+ return res;
958
+ }
959
+ /**
960
+ * True when a 401 response should be treated as a signed-out verdict:
961
+ * `fetchWithBas` has already run the refresh-then-retry dance and cleared
962
+ * local storage iff the failure was definitive. If the BAS row is still
963
+ * present, the 401 rode a TRANSIENT refresh failure — the session is kept
964
+ * and callers must degrade gracefully (serve cache) instead of signing out.
965
+ */
966
+ async sessionWasCleared() {
967
+ return (await this.storage.get(KEY_BAS)) === null;
581
968
  }
582
969
  async dpopProof(method, url, accessToken) {
583
970
  const keypair = await this.getDpopKeypair();
@@ -673,6 +1060,18 @@ export class BravelyAccountManager {
673
1060
  function stripTrailingSlash(s) {
674
1061
  return s.endsWith("/") ? s.slice(0, -1) : s;
675
1062
  }
1063
+ /**
1064
+ * Normalize an optional install id: trim whitespace and collapse empty /
1065
+ * missing values to `null` so the manager's "is one set?" check is a simple
1066
+ * truthiness test. A non-empty value is passed through verbatim (the auth
1067
+ * server applies its own length cap on the `app_auth_codes` write).
1068
+ */
1069
+ function normalizeInstallId(value) {
1070
+ if (typeof value !== "string")
1071
+ return null;
1072
+ const trimmed = value.trim();
1073
+ return trimmed.length > 0 ? trimmed : null;
1074
+ }
676
1075
  function supportsDpopCrypto() {
677
1076
  return (typeof globalThis !== "undefined" &&
678
1077
  typeof globalThis.crypto !== "undefined" &&