@oxy.so/services 4.0.2 → 4.0.4

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 (27) hide show
  1. package/lib/commonjs/ui/context/OxyContext.js +52 -20
  2. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  3. package/lib/commonjs/ui/runtime/createOxyRuntime.js +16 -0
  4. package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
  5. package/lib/commonjs/ui/session/tokenLossRecovery.js +122 -0
  6. package/lib/commonjs/ui/session/tokenLossRecovery.js.map +1 -0
  7. package/lib/module/ui/context/OxyContext.js +53 -21
  8. package/lib/module/ui/context/OxyContext.js.map +1 -1
  9. package/lib/module/ui/runtime/createOxyRuntime.js +16 -0
  10. package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
  11. package/lib/module/ui/session/tokenLossRecovery.js +116 -0
  12. package/lib/module/ui/session/tokenLossRecovery.js.map +1 -0
  13. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  14. package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts +7 -2
  15. package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts +37 -0
  17. package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts.map +1 -0
  18. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts +7 -2
  20. package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  21. package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts +37 -0
  22. package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts.map +1 -0
  23. package/package.json +3 -3
  24. package/src/ui/context/OxyContext.tsx +53 -23
  25. package/src/ui/runtime/createOxyRuntime.ts +23 -2
  26. package/src/ui/session/__tests__/tokenLossRecovery.test.ts +195 -0
  27. package/src/ui/session/tokenLossRecovery.ts +146 -0
@@ -83,7 +83,9 @@ export interface OxyRuntime {
83
83
  /**
84
84
  * Project the client's current device state onto the snapshot. Idempotent and
85
85
  * revision-guarded: a projection whose profile fetch was overtaken by a
86
- * fresher state discards itself rather than publishing a stale subject.
86
+ * fresher state discards itself rather than publishing a stale subject. It is
87
+ * also teardown-guarded: one started before {@link clearSession} publishes
88
+ * nothing, whatever `SessionClient` still holds.
87
89
  */
88
90
  reconcileFromClient(): Promise<void>;
89
91
  /** Read the device directory and publish it (opts this runtime into ADR 0002). */
@@ -137,7 +139,10 @@ export interface OxyRuntime {
137
139
  preserveSessionIds?: string[];
138
140
  }): void;
139
141
  setActiveSessionId(sessionId: string | null): void;
140
- /** Local teardown: drop the session projection without touching the server. */
142
+ /**
143
+ * Local teardown: drop the session projection without touching the server.
144
+ * Every projection already in flight is abandoned.
145
+ */
141
146
  clearSession(): void;
142
147
  }
143
148
  export declare function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime;
@@ -1 +1 @@
1
- {"version":3,"file":"createOxyRuntime.d.ts","sourceRoot":"","sources":["../../../../../src/ui/runtime/createOxyRuntime.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"createOxyRuntime.d.ts","sourceRoot":"","sources":["../../../../../src/ui/runtime/createOxyRuntime.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ export interface TokenLossRecoveryDeps {
2
+ /** Re-mint once through the installed refresh handler; the token, or null. */
3
+ remint: () => Promise<string | null>;
4
+ /** Whether the durable auth store still holds `deviceId` + `deviceSecret`. */
5
+ hasDeviceCredential: () => Promise<boolean>;
6
+ /**
7
+ * Whether a key-based lane could still re-establish the session without a
8
+ * device secret. Consulted only once the credential is gone.
9
+ */
10
+ hasKeyedRecovery: () => Promise<boolean>;
11
+ /** Whether the UI still shows a signed-in user. */
12
+ isSignedIn: () => boolean;
13
+ /** Whether a bearer is planted right now. */
14
+ hasToken: () => boolean;
15
+ /** Tear down the local session (the provider's `clearSessionState`). */
16
+ signOutLocally: () => Promise<void>;
17
+ /** Timer seam for tests. */
18
+ sleep?: (ms: number) => Promise<void>;
19
+ }
20
+ export interface TokenLossRecovery {
21
+ /** Start recovering, unless a recovery is already running. */
22
+ start(): void;
23
+ /** Abandon any running recovery (provider unmount). */
24
+ dispose(): void;
25
+ /** Whether a recovery is running. */
26
+ isRecovering(): boolean;
27
+ }
28
+ /**
29
+ * Delays between re-mint attempts while the device credential is intact. Starts
30
+ * near the post-failure refresh cooldowns and settles at one attempt a minute,
31
+ * which also respects the mint's own 60s rate-limit cooldown.
32
+ */
33
+ export declare const TOKEN_RECOVERY_BACKOFF_MS: readonly number[];
34
+ /** Attempts through a key-based lane once the device credential is gone. */
35
+ export declare const MAX_KEYED_RECOVERY_ATTEMPTS = 3;
36
+ export declare function createTokenLossRecovery(deps: TokenLossRecoveryDeps): TokenLossRecovery;
37
+ //# sourceMappingURL=tokenLossRecovery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenLossRecovery.d.ts","sourceRoot":"","sources":["../../../../../src/ui/session/tokenLossRecovery.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,qBAAqB;IACpC,8EAA8E;IAC9E,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrC,8EAA8E;IAC9E,mBAAmB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C;;;OAGG;IACH,gBAAgB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,mDAAmD;IACnD,UAAU,EAAE,MAAM,OAAO,CAAC;IAC1B,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,wEAAwE;IACxE,cAAc,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,KAAK,IAAI,IAAI,CAAC;IACd,uDAAuD;IACvD,OAAO,IAAI,IAAI,CAAC;IAChB,qCAAqC;IACrC,YAAY,IAAI,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,SAAS,MAAM,EAAmD,CAAC;AAE3G,4EAA4E;AAC5E,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAS7C,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CAoEtF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -164,7 +164,7 @@
164
164
  "@commitlint/cli": "^21.2.1",
165
165
  "@commitlint/config-conventional": "^21.2.0",
166
166
  "@expo/vector-icons": "^15.0.3",
167
- "@oxy.so/core": "1.9.0",
167
+ "@oxy.so/core": "1.10.0",
168
168
  "@react-native-async-storage/async-storage": "^2.0.0",
169
169
  "@react-native-community/netinfo": "^12.0.1",
170
170
  "@react-native/babel-preset": "^0.86.2",
@@ -216,7 +216,7 @@
216
216
  "peerDependencies": {
217
217
  "@expo/vector-icons": "^15.0.3",
218
218
  "@oxy.so/bloom": "^4.21.1",
219
- "@oxy.so/core": "^1.9.0",
219
+ "@oxy.so/core": "^1.9.1",
220
220
  "@react-native-async-storage/async-storage": "^2.0.0",
221
221
  "@react-native-community/netinfo": ">=11.4.1",
222
222
  "@tanstack/query-async-storage-persister": "^5.101",
@@ -45,7 +45,8 @@ import {
45
45
  import { isWebBrowser } from '../utils/isWebBrowser';
46
46
  import { resolveDeliveryPlatform } from '../utils/deliveryPlatform';
47
47
  import { runProviderColdBoot } from '../boot/runProviderColdBoot';
48
- import { loadPersistedDeviceCredential } from '../utils/deviceCredential';
48
+ import { hasPersistedDeviceCredential, loadPersistedDeviceCredential } from '../utils/deviceCredential';
49
+ import { createTokenLossRecovery } from '../session/tokenLossRecovery';
49
50
  import { bindAuthStoreToRuntime } from '../stores/authStore';
50
51
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
51
52
  import { useSessionManagement } from '../hooks/useSessionManagement';
@@ -543,14 +544,52 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
543
544
  });
544
545
 
545
546
 
546
- // Token-change side effects: an invalidated bearer (HttpService clears tokens
547
- // on an unrecoverable 401 and emits `null`) must locally sign out an
548
- // authenticated user so `isAuthenticated` never lingers true with no token.
549
- // The persisted store is NOT cleared here — the refresh handler already
550
- // cleared it if the family was revoked; a transient null leaves it intact so a
551
- // later reload can still restore.
547
+ // Token-change side effects. HttpService clears the bearer and emits `null`
548
+ // when a request draws a 401 its refresh could not answer — which happens on a
549
+ // transient failure (network, a cooling-down or rate-limited mint) as well as
550
+ // on a real revocation. A signed-in user is therefore NOT signed out on the
551
+ // spot: `tokenLossRecovery` keeps the session while the durable device
552
+ // credential survives and re-mints it, the way a relaunch would, and signs out
553
+ // only once the refresh handler has dropped that credential on a definitive
554
+ // server verdict (OxyHQ/Mention#1140). Until a token is back, `tokenReady` is
555
+ // false, so private queries wait instead of 401-ing.
552
556
  const clearingInvalidTokenRef = useRef(false);
553
557
  useEffect(() => {
558
+ const signOutLocally = async (): Promise<void> => {
559
+ if (clearingInvalidTokenRef.current) {
560
+ return;
561
+ }
562
+ clearingInvalidTokenRef.current = true;
563
+ try {
564
+ await clearSessionStateRef.current();
565
+ } catch (clearError) {
566
+ logger('Failed to clear invalidated auth session', clearError);
567
+ } finally {
568
+ clearingInvalidTokenRef.current = false;
569
+ if (runtime.getSnapshot().authResolved) {
570
+ runtime.setTokenReady(true);
571
+ }
572
+ }
573
+ };
574
+ const recovery = createTokenLossRecovery({
575
+ remint: () => oxyServices.httpService.refreshAccessToken('preflight'),
576
+ hasDeviceCredential: () => hasPersistedDeviceCredential(authStore),
577
+ hasKeyedRecovery: async () => {
578
+ if (Platform.OS === 'web') {
579
+ return false;
580
+ }
581
+ try {
582
+ // The same keys the refresh handler's second arm signs with: the
583
+ // identity-bound client's own key, or the cross-app shared identity.
584
+ return identity ? await KeyManager.hasIdentity() : await KeyManager.hasSharedIdentity();
585
+ } catch {
586
+ return false;
587
+ }
588
+ },
589
+ isSignedIn: () => runtime.getSnapshot().account !== null,
590
+ hasToken: () => Boolean(oxyServices.getAccessToken()),
591
+ signOutLocally,
592
+ });
554
593
  const handleTokenChange = (accessToken: string | null) => {
555
594
  runtime.setHasAccessToken(Boolean(accessToken));
556
595
  if (accessToken) {
@@ -562,20 +601,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
562
601
  }
563
602
  if (runtime.getSnapshot().account !== null) {
564
603
  runtime.setTokenReady(false);
565
- if (clearingInvalidTokenRef.current) {
566
- return;
567
- }
568
- clearingInvalidTokenRef.current = true;
569
- clearSessionStateRef.current()
570
- .catch((clearError) => {
571
- logger('Failed to clear invalidated auth session', clearError);
572
- })
573
- .finally(() => {
574
- clearingInvalidTokenRef.current = false;
575
- if (runtime.getSnapshot().authResolved) {
576
- runtime.setTokenReady(true);
577
- }
578
- });
604
+ recovery.start();
579
605
  return;
580
606
  }
581
607
  if (runtime.getSnapshot().authResolved) {
@@ -583,8 +609,12 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
583
609
  }
584
610
  };
585
611
  handleTokenChange(oxyServices.getAccessToken());
586
- return oxyServices.onTokensChanged(handleTokenChange);
587
- }, [logger, oxyServices, runtime, sessionClient]);
612
+ const unsubscribe = oxyServices.onTokensChanged(handleTokenChange);
613
+ return () => {
614
+ unsubscribe();
615
+ recovery.dispose();
616
+ };
617
+ }, [logger, oxyServices, runtime, sessionClient, authStore, identity]);
588
618
 
589
619
  // Unified in-session refresh (SDK-owned; every RP inherits it). Installs the
590
620
  // ONE core refresh handler (re-mint from the persisted zero-cookie device
@@ -107,7 +107,9 @@ export interface OxyRuntime {
107
107
  /**
108
108
  * Project the client's current device state onto the snapshot. Idempotent and
109
109
  * revision-guarded: a projection whose profile fetch was overtaken by a
110
- * fresher state discards itself rather than publishing a stale subject.
110
+ * fresher state discards itself rather than publishing a stale subject. It is
111
+ * also teardown-guarded: one started before {@link clearSession} publishes
112
+ * nothing, whatever `SessionClient` still holds.
111
113
  */
112
114
  reconcileFromClient(): Promise<void>;
113
115
 
@@ -166,7 +168,10 @@ export interface OxyRuntime {
166
168
  mergeSessions(incoming: ClientSession[], options?: { merge?: boolean; preserveSessionIds?: string[] }): void;
167
169
  setActiveSessionId(sessionId: string | null): void;
168
170
 
169
- /** Local teardown: drop the session projection without touching the server. */
171
+ /**
172
+ * Local teardown: drop the session projection without touching the server.
173
+ * Every projection already in flight is abandoned.
174
+ */
170
175
  clearSession(): void;
171
176
  }
172
177
 
@@ -268,6 +273,14 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
268
273
  let dirty = false;
269
274
  /** The subject as it stood when the OUTERMOST transition opened. */
270
275
  let subjectAtEntry: string | null = null;
276
+ /**
277
+ * Bumped by every local teardown. A projection captures it before its first
278
+ * await and discards itself if it moved: the revision guard alone cannot see
279
+ * a sign-out, because a local teardown (the token-null lane, a `logoutAll`
280
+ * whose device call already failed) leaves `SessionClient` holding the SAME
281
+ * state, at the same revision, that the projection was started for.
282
+ */
283
+ let sessionGeneration = 0;
271
284
 
272
285
  function buildSnapshot(): OxyRuntimeSnapshot {
273
286
  const activeContext = resolveActiveContext(facts.directory);
@@ -401,6 +414,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
401
414
  // profile fetch if a fresher state has landed — last-writer-wins, so two
402
415
  // overlapping projections can never publish in the wrong order.
403
416
  const capturedRevision = state.revision;
417
+ const capturedGeneration = sessionGeneration;
404
418
  // Resolve the pin BEFORE the fetch (memoised: one storage + keychain read
405
419
  // per boot) so the first pass over a freshly applied state is already bound.
406
420
  // Deferring it would let a sibling app's switch render once as this
@@ -414,6 +428,12 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
414
428
  logger('Failed to resolve account profiles during the device projection', fetchError);
415
429
  return;
416
430
  }
431
+ if (capturedGeneration !== sessionGeneration) {
432
+ // Signed out while the profiles were in flight. Committing now would put
433
+ // the account back on a device with no bearer — the "Hi <old user>"
434
+ // greeting to a signed-out visitor.
435
+ return;
436
+ }
417
437
  const latest = sessionClient.getState();
418
438
  if (!latest || latest.revision !== capturedRevision) {
419
439
  return;
@@ -608,6 +628,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
608
628
  },
609
629
 
610
630
  clearSession() {
631
+ sessionGeneration += 1;
611
632
  transition(() => {
612
633
  setSessions(EMPTY_SESSIONS);
613
634
  setActiveSessionIdFact(null);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * A lost access token is a question, not a sign-out (OxyHQ/Mention#1140).
3
+ *
4
+ * The provider used to sign the user out on ANY cleared bearer. HttpService
5
+ * clears it whenever a 401's refresh comes back empty — including when the mint
6
+ * is merely cooling down after a 429 or the network blipped — and with no token
7
+ * the refresh scheduler stops, so the session never came back until a relaunch
8
+ * re-minted from the device credential that had been intact the whole time.
9
+ */
10
+ import {
11
+ createTokenLossRecovery,
12
+ MAX_KEYED_RECOVERY_ATTEMPTS,
13
+ TOKEN_RECOVERY_BACKOFF_MS,
14
+ type TokenLossRecoveryDeps,
15
+ } from '../tokenLossRecovery';
16
+
17
+ interface Harness {
18
+ deps: TokenLossRecoveryDeps;
19
+ state: { signedIn: boolean; token: string | null; credential: boolean; keyed: boolean };
20
+ remint: jest.Mock<Promise<string | null>, []>;
21
+ signOutLocally: jest.Mock<Promise<void>, []>;
22
+ sleeps: number[];
23
+ }
24
+
25
+ function harness(remintAnswers: Array<string | null | 'drop-credential'>): Harness {
26
+ const state = { signedIn: true, token: null as string | null, credential: true, keyed: false };
27
+ const sleeps: number[] = [];
28
+ const remint = jest.fn(async (): Promise<string | null> => {
29
+ const answer = remintAnswers.length > 0 ? remintAnswers.shift() : null;
30
+ if (answer === 'drop-credential') {
31
+ // What the refresh handler does on `invalid_device_secret` /
32
+ // `no_active_session`: the credential goes, and the mint returns nothing.
33
+ state.credential = false;
34
+ return null;
35
+ }
36
+ if (answer) {
37
+ state.token = answer;
38
+ }
39
+ return answer ?? null;
40
+ });
41
+ const signOutLocally = jest.fn(async () => {
42
+ state.signedIn = false;
43
+ });
44
+ const deps: TokenLossRecoveryDeps = {
45
+ remint,
46
+ hasDeviceCredential: async () => state.credential,
47
+ hasKeyedRecovery: async () => state.keyed,
48
+ isSignedIn: () => state.signedIn,
49
+ hasToken: () => state.token !== null,
50
+ signOutLocally,
51
+ sleep: async (ms) => {
52
+ sleeps.push(ms);
53
+ },
54
+ };
55
+ return { deps, state, remint, signOutLocally, sleeps };
56
+ }
57
+
58
+ async function runToCompletion(recovery: ReturnType<typeof createTokenLossRecovery>): Promise<void> {
59
+ for (let i = 0; i < 200 && recovery.isRecovering(); i += 1) {
60
+ await new Promise((resolve) => setTimeout(resolve, 0));
61
+ }
62
+ expect(recovery.isRecovering()).toBe(false);
63
+ }
64
+
65
+ describe('token loss recovery', () => {
66
+ it('keeps a user with an intact device credential signed in through transient failures, then restores the token', async () => {
67
+ // The incident: the mint is rate limited / cooling down for a while, then
68
+ // succeeds — exactly what a relaunch a few minutes later did.
69
+ const h = harness([null, null, null, 'fresh']);
70
+ const recovery = createTokenLossRecovery(h.deps);
71
+
72
+ recovery.start();
73
+ await runToCompletion(recovery);
74
+
75
+ expect(h.signOutLocally).not.toHaveBeenCalled();
76
+ expect(h.state.token).toBe('fresh');
77
+ expect(h.remint).toHaveBeenCalledTimes(4);
78
+ expect(h.sleeps).toEqual(TOKEN_RECOVERY_BACKOFF_MS.slice(0, 3));
79
+ });
80
+
81
+ it('signs out once a re-mint gets a definitive verdict and no key can re-establish the session', async () => {
82
+ const h = harness([null, 'drop-credential']);
83
+ const recovery = createTokenLossRecovery(h.deps);
84
+
85
+ recovery.start();
86
+ await runToCompletion(recovery);
87
+
88
+ expect(h.signOutLocally).toHaveBeenCalledTimes(1);
89
+ expect(h.remint).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it('signs out at once when the credential was already gone and there is no key-based lane (the old behaviour for a real revocation)', async () => {
93
+ const h = harness([]);
94
+ h.state.credential = false;
95
+ const recovery = createTokenLossRecovery(h.deps);
96
+
97
+ recovery.start();
98
+ await runToCompletion(recovery);
99
+
100
+ expect(h.remint).not.toHaveBeenCalled();
101
+ expect(h.signOutLocally).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('re-establishes through the shared identity when the credential is gone, like the cold boot does', async () => {
105
+ const h = harness([null, 'from-shared-key']);
106
+ h.state.credential = false;
107
+ h.state.keyed = true;
108
+ const recovery = createTokenLossRecovery(h.deps);
109
+
110
+ recovery.start();
111
+ await runToCompletion(recovery);
112
+
113
+ expect(h.signOutLocally).not.toHaveBeenCalled();
114
+ expect(h.state.token).toBe('from-shared-key');
115
+ });
116
+
117
+ it('gives the key-based lane a bounded number of tries, then signs out', async () => {
118
+ const h = harness([]);
119
+ h.state.credential = false;
120
+ h.state.keyed = true;
121
+ const recovery = createTokenLossRecovery(h.deps);
122
+
123
+ recovery.start();
124
+ await runToCompletion(recovery);
125
+
126
+ expect(h.remint).toHaveBeenCalledTimes(MAX_KEYED_RECOVERY_ATTEMPTS);
127
+ expect(h.signOutLocally).toHaveBeenCalledTimes(1);
128
+ });
129
+
130
+ it('stops without touching the session when the user signs out meanwhile', async () => {
131
+ const h = harness([]);
132
+ h.remint.mockImplementation(async () => {
133
+ h.state.signedIn = false;
134
+ return null;
135
+ });
136
+ const recovery = createTokenLossRecovery(h.deps);
137
+
138
+ recovery.start();
139
+ await runToCompletion(recovery);
140
+
141
+ expect(h.remint).toHaveBeenCalledTimes(1);
142
+ expect(h.signOutLocally).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it('stops when another lane planted a token first', async () => {
146
+ const h = harness([]);
147
+ h.remint.mockImplementation(async () => {
148
+ h.state.token = 'planted-by-a-sign-in';
149
+ return null;
150
+ });
151
+ const recovery = createTokenLossRecovery(h.deps);
152
+
153
+ recovery.start();
154
+ await runToCompletion(recovery);
155
+
156
+ expect(h.remint).toHaveBeenCalledTimes(1);
157
+ expect(h.signOutLocally).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('runs one recovery at a time', async () => {
161
+ const h = harness([null, 'fresh']);
162
+ const recovery = createTokenLossRecovery(h.deps);
163
+
164
+ recovery.start();
165
+ recovery.start();
166
+ await runToCompletion(recovery);
167
+
168
+ expect(h.remint).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it('signs out rather than strand a signed-in UI with no bearer when recovery itself throws', async () => {
172
+ const h = harness([]);
173
+ h.deps.hasDeviceCredential = async () => {
174
+ throw new Error('store exploded');
175
+ };
176
+ const recovery = createTokenLossRecovery(h.deps);
177
+
178
+ recovery.start();
179
+ await runToCompletion(recovery);
180
+
181
+ expect(h.signOutLocally).toHaveBeenCalledTimes(1);
182
+ });
183
+
184
+ it('does nothing after dispose', async () => {
185
+ const h = harness([]);
186
+ const recovery = createTokenLossRecovery(h.deps);
187
+
188
+ recovery.dispose();
189
+ recovery.start();
190
+ await runToCompletion(recovery);
191
+
192
+ expect(h.remint).not.toHaveBeenCalled();
193
+ expect(h.signOutLocally).not.toHaveBeenCalled();
194
+ });
195
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a signed-in app does when its access token disappears at runtime.
3
+ *
4
+ * `HttpService` clears the bearer when a request draws a 401 and the refresh it
5
+ * then tries returns nothing. That refresh can return nothing for reasons that
6
+ * say NOTHING about the session: the mint is cooling down after a failure or a
7
+ * 429, the network dropped, the server was restarting. The provider used to read
8
+ * every such clear as a sign-out and tore the session down on the spot — and
9
+ * because the proactive refresh scheduler only runs while a token exists, nothing
10
+ * was left to bring the session back. Only a relaunch did, because the cold boot
11
+ * mints from the durable device credential that had been there all along
12
+ * (OxyHQ/Mention#1140).
13
+ *
14
+ * So a lost token is a question, and this answers it the way a relaunch would:
15
+ *
16
+ * - The durable store still holds the device credential → the session is NOT
17
+ * known to be over. Keep the user signed in (private queries pause, because
18
+ * there is no bearer) and re-mint with backoff until one succeeds. A
19
+ * definitive server verdict (`invalid_device_secret`, `no_active_session`)
20
+ * makes the refresh handler drop or clear that credential, which is what ends
21
+ * this loop in a sign-out — so a real revocation still signs out.
22
+ * - No credential, but a key-based lane can still re-establish (the native
23
+ * shared identity, or an identity-bound client's own key) → try that a few
24
+ * times, as the cold boot's shared-key step would, then sign out.
25
+ * - Neither → the session is over. Sign out now, as before.
26
+ *
27
+ * Framework-free and clock-injectable so the policy is unit tested without a
28
+ * provider.
29
+ */
30
+ import { logger } from '@oxy.so/core';
31
+
32
+ export interface TokenLossRecoveryDeps {
33
+ /** Re-mint once through the installed refresh handler; the token, or null. */
34
+ remint: () => Promise<string | null>;
35
+ /** Whether the durable auth store still holds `deviceId` + `deviceSecret`. */
36
+ hasDeviceCredential: () => Promise<boolean>;
37
+ /**
38
+ * Whether a key-based lane could still re-establish the session without a
39
+ * device secret. Consulted only once the credential is gone.
40
+ */
41
+ hasKeyedRecovery: () => Promise<boolean>;
42
+ /** Whether the UI still shows a signed-in user. */
43
+ isSignedIn: () => boolean;
44
+ /** Whether a bearer is planted right now. */
45
+ hasToken: () => boolean;
46
+ /** Tear down the local session (the provider's `clearSessionState`). */
47
+ signOutLocally: () => Promise<void>;
48
+ /** Timer seam for tests. */
49
+ sleep?: (ms: number) => Promise<void>;
50
+ }
51
+
52
+ export interface TokenLossRecovery {
53
+ /** Start recovering, unless a recovery is already running. */
54
+ start(): void;
55
+ /** Abandon any running recovery (provider unmount). */
56
+ dispose(): void;
57
+ /** Whether a recovery is running. */
58
+ isRecovering(): boolean;
59
+ }
60
+
61
+ /**
62
+ * Delays between re-mint attempts while the device credential is intact. Starts
63
+ * near the post-failure refresh cooldowns and settles at one attempt a minute,
64
+ * which also respects the mint's own 60s rate-limit cooldown.
65
+ */
66
+ export const TOKEN_RECOVERY_BACKOFF_MS: readonly number[] = [2_000, 5_000, 10_000, 20_000, 30_000, 60_000];
67
+
68
+ /** Attempts through a key-based lane once the device credential is gone. */
69
+ export const MAX_KEYED_RECOVERY_ATTEMPTS = 3;
70
+
71
+ const defaultSleep = (ms: number): Promise<void> =>
72
+ new Promise((resolve) => {
73
+ setTimeout(resolve, ms);
74
+ });
75
+
76
+ const LOG_CONTEXT = { component: 'OxyContext', method: 'tokenLossRecovery' } as const;
77
+
78
+ export function createTokenLossRecovery(deps: TokenLossRecoveryDeps): TokenLossRecovery {
79
+ const sleep = deps.sleep ?? defaultSleep;
80
+ let running = false;
81
+ let disposed = false;
82
+
83
+ const settled = (): boolean => disposed || deps.hasToken() || !deps.isSignedIn();
84
+
85
+ const run = async (): Promise<void> => {
86
+ let attempt = 0;
87
+ let keyedAttempts = 0;
88
+ logger.warn('Access token lost while signed in — recovering the session instead of signing out', LOG_CONTEXT);
89
+ while (!settled()) {
90
+ if (!(await deps.hasDeviceCredential())) {
91
+ const keyed = keyedAttempts < MAX_KEYED_RECOVERY_ATTEMPTS && (await deps.hasKeyedRecovery());
92
+ if (!keyed) {
93
+ if (settled()) {
94
+ return;
95
+ }
96
+ logger.warn('Session recovery ended: no device credential and no key-based lane left — signing out', {
97
+ ...LOG_CONTEXT,
98
+ attempts: attempt,
99
+ keyedAttempts,
100
+ });
101
+ await deps.signOutLocally();
102
+ return;
103
+ }
104
+ keyedAttempts += 1;
105
+ }
106
+ if (settled()) {
107
+ return;
108
+ }
109
+ const token = await deps.remint().catch(() => null);
110
+ attempt += 1;
111
+ if (token || settled()) {
112
+ if (token) {
113
+ logger.warn('Session recovered after a lost access token', { ...LOG_CONTEXT, attempts: attempt });
114
+ }
115
+ return;
116
+ }
117
+ await sleep(TOKEN_RECOVERY_BACKOFF_MS[Math.min(attempt - 1, TOKEN_RECOVERY_BACKOFF_MS.length - 1)]);
118
+ }
119
+ };
120
+
121
+ return {
122
+ start(): void {
123
+ if (running || disposed) {
124
+ return;
125
+ }
126
+ running = true;
127
+ void run()
128
+ .catch(async (error: unknown) => {
129
+ // Never leave a signed-in UI with no bearer and nothing recovering it.
130
+ logger.warn('Session recovery failed unexpectedly — signing out', LOG_CONTEXT, error);
131
+ if (!settled()) {
132
+ await deps.signOutLocally().catch(() => undefined);
133
+ }
134
+ })
135
+ .finally(() => {
136
+ running = false;
137
+ });
138
+ },
139
+ dispose(): void {
140
+ disposed = true;
141
+ },
142
+ isRecovering(): boolean {
143
+ return running;
144
+ },
145
+ };
146
+ }