@oxy.so/core 4.1.0 → 4.2.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.
- package/dist/cjs/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +23 -15
- package/dist/cjs/server/OxyServer.js +107 -51
- package/dist/cjs/server/agentAccount.js +19 -0
- package/dist/cjs/server/auth.js +19 -0
- package/dist/cjs/server/capabilityTicket.js +3 -41
- package/dist/cjs/server/index.js +10 -2
- package/dist/cjs/server/liveCapabilityTicket.js +48 -0
- package/dist/cjs/server/middleware.js +60 -18
- package/dist/cjs/server/namespaces.js +57 -0
- package/dist/cjs/server/serviceTokenLifetime.js +13 -0
- package/dist/cjs/session/SessionClient.js +90 -12
- package/dist/cjs/session/authStateStore.js +130 -39
- package/dist/cjs/session/refresh.js +152 -30
- package/dist/cjs/session/sharedDeviceCredential.js +17 -1
- package/dist/cjs/utils/officialOrigins.js +7 -18
- package/dist/esm/HttpService.js +143 -34
- package/dist/esm/api/accounts.js +38 -0
- package/dist/esm/api/apps.js +2 -2
- package/dist/esm/api/auth.js +23 -3
- package/dist/esm/api/billing.js +35 -0
- package/dist/esm/boot/sessionColdBoot.js +29 -58
- package/dist/esm/inference/OxyInferenceClient.js +21 -13
- package/dist/esm/server/OxyServer.js +107 -51
- package/dist/esm/server/agentAccount.js +16 -0
- package/dist/esm/server/auth.js +18 -0
- package/dist/esm/server/capabilityTicket.js +1 -39
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/liveCapabilityTicket.js +45 -0
- package/dist/esm/server/middleware.js +60 -18
- package/dist/esm/server/namespaces.js +57 -0
- package/dist/esm/server/serviceTokenLifetime.js +9 -0
- package/dist/esm/session/SessionClient.js +90 -12
- package/dist/esm/session/authStateStore.js +128 -38
- package/dist/esm/session/refresh.js +151 -30
- package/dist/esm/session/sharedDeviceCredential.js +17 -1
- package/dist/esm/utils/officialOrigins.js +2 -13
- package/dist/types/HttpService.d.ts +26 -12
- package/dist/types/api/accounts.d.ts +38 -0
- package/dist/types/api/agency.d.ts +3 -1
- package/dist/types/api/apps.d.ts +3 -1
- package/dist/types/api/auth.d.ts +12 -1
- package/dist/types/api/billing.d.ts +12 -0
- package/dist/types/api/session.d.ts +3 -1
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +9 -0
- package/dist/types/inference/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +16 -9
- package/dist/types/server/agentAccount.d.ts +14 -0
- package/dist/types/server/auth.d.ts +18 -0
- package/dist/types/server/capabilityTicket.d.ts +2 -4
- package/dist/types/server/index.d.ts +7 -1
- package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
- package/dist/types/server/middleware.d.ts +15 -6
- package/dist/types/server/namespaces.d.ts +78 -2
- package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/dist/types/session/authStateStore.d.ts +15 -0
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +32 -7
- package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
- package/dist/types/utils/officialOrigins.d.ts +1 -5
- package/package.json +6 -6
- package/src/HttpService.ts +138 -50
- package/src/api/accounts.ts +22 -0
- package/src/api/agency.ts +3 -1
- package/src/api/apps.ts +2 -2
- package/src/api/auth.ts +31 -2
- package/src/api/billing.ts +71 -9
- package/src/api/session.ts +3 -1
- package/src/boot/sessionColdBoot.ts +27 -72
- package/src/index.ts +1 -1
- package/src/inference/OxyInferenceClient.ts +19 -5
- package/src/inference/index.ts +1 -0
- package/src/server/OxyServer.ts +114 -53
- package/src/server/agentAccount.ts +28 -0
- package/src/server/auth.ts +25 -0
- package/src/server/capabilityTicket.ts +2 -42
- package/src/server/index.ts +13 -0
- package/src/server/liveCapabilityTicket.ts +47 -0
- package/src/server/middleware.ts +72 -25
- package/src/server/namespaces.ts +99 -2
- package/src/server/serviceTokenLifetime.ts +13 -0
- package/src/session/SessionClient.ts +85 -12
- package/src/session/authStateStore.ts +130 -46
- package/src/session/index.ts +1 -0
- package/src/session/refresh.ts +144 -28
- package/src/session/sharedDeviceCredential.ts +14 -2
- package/src/utils/officialOrigins.ts +2 -13
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '@oxy.so/contracts';
|
|
14
14
|
import { logger } from '../logger';
|
|
15
15
|
import { computeIdentityTag } from '../utils/cacheKey';
|
|
16
|
+
import { decodeTokenClaims } from '../utils/tokenClaims';
|
|
16
17
|
import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
|
|
17
18
|
import { getSocketIO } from './socketLoader';
|
|
18
19
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
@@ -55,6 +56,8 @@ export interface SessionClientHost {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export interface SessionClientOptions {
|
|
59
|
+
/** Await durable local logout intent after an explicit removal or an applied nonempty→empty device transition. */
|
|
60
|
+
onFullExplicitSignOut?: () => Promise<void>;
|
|
58
61
|
transport?: TokenTransport;
|
|
59
62
|
/**
|
|
60
63
|
* Invoked when an APPLIED state has zero accounts — i.e. a device
|
|
@@ -142,6 +145,8 @@ export class SessionClient {
|
|
|
142
145
|
protected socket: MinimalSocket | null = null;
|
|
143
146
|
private tokenUnsub: (() => void) | null = null;
|
|
144
147
|
private started = false;
|
|
148
|
+
private lifecycleGeneration = 0;
|
|
149
|
+
private fullSignOutPersistence: { state: DeviceSessionState; pending: Promise<void> } | null = null;
|
|
145
150
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
146
151
|
private channel: SessionBroadcastChannel | null = null;
|
|
147
152
|
/** App-facing subscriptions to named server-pushed socket events. */
|
|
@@ -154,6 +159,34 @@ export class SessionClient {
|
|
|
154
159
|
protected readonly options: SessionClientOptions = {},
|
|
155
160
|
) {}
|
|
156
161
|
|
|
162
|
+
private persistFullSignOut(): Promise<void> {
|
|
163
|
+
const state = this.state;
|
|
164
|
+
if (!state || !this.options.onFullExplicitSignOut) return Promise.resolve();
|
|
165
|
+
if (this.fullSignOutPersistence?.state === state) return this.fullSignOutPersistence.pending;
|
|
166
|
+
const pending = this.options.onFullExplicitSignOut();
|
|
167
|
+
this.fullSignOutPersistence = { state, pending };
|
|
168
|
+
void pending.catch(() => {
|
|
169
|
+
if (this.fullSignOutPersistence?.pending === pending) this.fullSignOutPersistence = null;
|
|
170
|
+
});
|
|
171
|
+
return pending;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async requestDevice<T>(method: 'GET' | 'POST', url: string, data?: unknown, requestOptions?: { cache?: boolean }): Promise<T> {
|
|
175
|
+
const generation = this.lifecycleGeneration;
|
|
176
|
+
const response = await this.host.makeRequest<T>(method, url, data, requestOptions);
|
|
177
|
+
if (generation !== this.lifecycleGeneration) throw new Error('Device session request was superseded');
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
182
|
+
resetLocalState(): void {
|
|
183
|
+
this.lifecycleGeneration += 1;
|
|
184
|
+
this.stop();
|
|
185
|
+
this.state = null;
|
|
186
|
+
this.directory = null;
|
|
187
|
+
this.notify();
|
|
188
|
+
}
|
|
189
|
+
|
|
157
190
|
getState(): DeviceSessionState | null {
|
|
158
191
|
return this.state;
|
|
159
192
|
}
|
|
@@ -280,6 +313,7 @@ export class SessionClient {
|
|
|
280
313
|
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
281
314
|
*/
|
|
282
315
|
protected applyState(raw: unknown, origin: SessionStateOrigin = 'push', activeToken?: string): boolean {
|
|
316
|
+
const generation = this.lifecycleGeneration;
|
|
283
317
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
284
318
|
if (!next) {
|
|
285
319
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -341,6 +375,7 @@ export class SessionClient {
|
|
|
341
375
|
(activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
|
|
342
376
|
|
|
343
377
|
const publish = (): void => {
|
|
378
|
+
if (generation !== this.lifecycleGeneration || this.state !== next) return;
|
|
344
379
|
this.notify();
|
|
345
380
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
346
381
|
try {
|
|
@@ -359,6 +394,7 @@ export class SessionClient {
|
|
|
359
394
|
// (and this stays synchronous) for every client that never read a
|
|
360
395
|
// directory, i.e. the whole account lane.
|
|
361
396
|
const finishApply = (): void => {
|
|
397
|
+
if (generation !== this.lifecycleGeneration || this.state !== next) return;
|
|
362
398
|
const settling = this.settleDirectory(next);
|
|
363
399
|
if (settling === null) {
|
|
364
400
|
publish();
|
|
@@ -367,8 +403,39 @@ export class SessionClient {
|
|
|
367
403
|
void settling.then(publish);
|
|
368
404
|
};
|
|
369
405
|
|
|
406
|
+
// A sibling's validated full removal is logout here too. Persist the local
|
|
407
|
+
// account-mode recovery suppression before publishing signed-out or running
|
|
408
|
+
// the empty-state refresh (which could otherwise challenge Commons again).
|
|
409
|
+
// Keep the push credential intact; a marker is not a destructive wipe.
|
|
410
|
+
// Unknown initial empty state, stale revisions and identity pins do not
|
|
411
|
+
// turn into logout intent. A newer state/lifecycle suppresses this publish.
|
|
412
|
+
const priorBearer = this.host.getAccessToken();
|
|
413
|
+
const bearerAccount = priorBearer ? decodeTokenClaims(priorBearer)?.userId : null;
|
|
414
|
+
// Warm cold boot may fetch authoritative empty state before it ever had a
|
|
415
|
+
// local projection. Its authenticated holder is history too. Decoding only
|
|
416
|
+
// identifies local history; authority remains the validated REST response.
|
|
417
|
+
const warmHolderEnded = previousState === null && origin === 'request' &&
|
|
418
|
+
typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
|
|
419
|
+
this.host.getDeviceCredential()?.deviceId === next.deviceId;
|
|
420
|
+
if (
|
|
421
|
+
((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
|
|
422
|
+
next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut
|
|
423
|
+
) {
|
|
424
|
+
try {
|
|
425
|
+
void this.persistFullSignOut().then(finishApply).catch((error) => {
|
|
426
|
+
logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
427
|
+
finishApply();
|
|
428
|
+
});
|
|
429
|
+
} catch (error) {
|
|
430
|
+
logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
431
|
+
finishApply();
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
370
436
|
if (needsMintBeforeNotify) {
|
|
371
437
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
438
|
+
if (generation !== this.lifecycleGeneration) return;
|
|
372
439
|
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
373
440
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
374
441
|
// state so subscribers keep observing the account whose token is planted.
|
|
@@ -392,7 +459,7 @@ export class SessionClient {
|
|
|
392
459
|
* the token still needs to be planted. The account-match guard rejects a stale response for an
|
|
393
460
|
* account that is no longer active.
|
|
394
461
|
*/
|
|
395
|
-
private applySync(raw: unknown): void {
|
|
462
|
+
private async applySync(raw: unknown): Promise<void> {
|
|
396
463
|
const sync = safeParseContract(deviceSessionSyncSchema, raw);
|
|
397
464
|
if (!sync) {
|
|
398
465
|
const parsed = deviceSessionSyncSchema.safeParse(raw);
|
|
@@ -411,6 +478,7 @@ export class SessionClient {
|
|
|
411
478
|
return;
|
|
412
479
|
}
|
|
413
480
|
this.commitSync(sync);
|
|
481
|
+
if (this.fullSignOutPersistence?.state === this.state) await this.fullSignOutPersistence.pending;
|
|
414
482
|
}
|
|
415
483
|
|
|
416
484
|
/**
|
|
@@ -495,7 +563,7 @@ export class SessionClient {
|
|
|
495
563
|
|
|
496
564
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
497
565
|
private async fetchDirectory(): Promise<boolean> {
|
|
498
|
-
const res = await this.
|
|
566
|
+
const res = await this.requestDevice<unknown>('GET', '/session/device/directory', undefined, { cache: false });
|
|
499
567
|
return this.applyDirectory(res);
|
|
500
568
|
}
|
|
501
569
|
|
|
@@ -611,8 +679,8 @@ export class SessionClient {
|
|
|
611
679
|
}
|
|
612
680
|
|
|
613
681
|
async bootstrap(): Promise<void> {
|
|
614
|
-
const res = await this.
|
|
615
|
-
this.applySync(res);
|
|
682
|
+
const res = await this.requestDevice<unknown>('GET', '/session/device/state', undefined, { cache: false });
|
|
683
|
+
await this.applySync(res);
|
|
616
684
|
}
|
|
617
685
|
|
|
618
686
|
/**
|
|
@@ -658,7 +726,7 @@ export class SessionClient {
|
|
|
658
726
|
* compatibility path for callers still keyed on account ids.
|
|
659
727
|
*/
|
|
660
728
|
async activateContext(contextId: string): Promise<void> {
|
|
661
|
-
const res = await this.
|
|
729
|
+
const res = await this.requestDevice<unknown>('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
662
730
|
const activation = safeParseContract(deviceActivateResponseSchema, res);
|
|
663
731
|
if (!activation) {
|
|
664
732
|
logger.warn('[SessionClient] discarded invalid activation response');
|
|
@@ -685,8 +753,8 @@ export class SessionClient {
|
|
|
685
753
|
}
|
|
686
754
|
|
|
687
755
|
async switchAccount(accountId: string): Promise<void> {
|
|
688
|
-
const res = await this.
|
|
689
|
-
this.applySync(res);
|
|
756
|
+
const res = await this.requestDevice<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
757
|
+
await this.applySync(res);
|
|
690
758
|
this.postCommitPing();
|
|
691
759
|
}
|
|
692
760
|
|
|
@@ -702,9 +770,10 @@ export class SessionClient {
|
|
|
702
770
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
703
771
|
*/
|
|
704
772
|
async signOut(target: { accountId: string } | { all: true }): Promise<void> {
|
|
705
|
-
const res = await this.
|
|
706
|
-
this.applySync(res);
|
|
773
|
+
const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, { cache: false });
|
|
774
|
+
await this.applySync(res);
|
|
707
775
|
this.postCommitPing();
|
|
776
|
+
if (this.state?.accounts.length === 0) await this.persistFullSignOut();
|
|
708
777
|
}
|
|
709
778
|
|
|
710
779
|
/**
|
|
@@ -746,7 +815,7 @@ export class SessionClient {
|
|
|
746
815
|
* applied this revision.
|
|
747
816
|
*/
|
|
748
817
|
private async removeFromDevice(target: { contextId: string } | { principalId: string }): Promise<void> {
|
|
749
|
-
const res = await this.
|
|
818
|
+
const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, { cache: false });
|
|
750
819
|
const removal = safeParseContract(deviceDirectorySyncSchema, res);
|
|
751
820
|
if (!removal) {
|
|
752
821
|
logger.warn('[SessionClient] discarded invalid device removal response');
|
|
@@ -754,6 +823,7 @@ export class SessionClient {
|
|
|
754
823
|
}
|
|
755
824
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
756
825
|
const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
|
|
826
|
+
if (this.fullSignOutPersistence?.state === this.state) await this.fullSignOutPersistence.pending;
|
|
757
827
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
758
828
|
// directory did — a socket push already applied this revision — the
|
|
759
829
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -761,11 +831,12 @@ export class SessionClient {
|
|
|
761
831
|
this.notify();
|
|
762
832
|
}
|
|
763
833
|
this.postCommitPing();
|
|
834
|
+
if (this.state?.accounts.length === 0) await this.persistFullSignOut();
|
|
764
835
|
}
|
|
765
836
|
|
|
766
837
|
async addCurrentAccount(): Promise<void> {
|
|
767
|
-
const res = await this.
|
|
768
|
-
this.applySync(res);
|
|
838
|
+
const res = await this.requestDevice<unknown>('POST', '/session/device/add', undefined, { cache: false });
|
|
839
|
+
await this.applySync(res);
|
|
769
840
|
this.postCommitPing();
|
|
770
841
|
}
|
|
771
842
|
|
|
@@ -866,6 +937,7 @@ export class SessionClient {
|
|
|
866
937
|
const deviceCredential = this.host.getDeviceCredential();
|
|
867
938
|
if (!token && !deviceCredential) return;
|
|
868
939
|
|
|
940
|
+
const generation = this.lifecycleGeneration;
|
|
869
941
|
const socket = io(this.host.getBaseURL(), {
|
|
870
942
|
transports: ['websocket'],
|
|
871
943
|
autoConnect: true,
|
|
@@ -890,6 +962,7 @@ export class SessionClient {
|
|
|
890
962
|
socket.on('session_state', (payload: unknown) => {
|
|
891
963
|
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
892
964
|
// empty state here must not erase the durable device credential.
|
|
965
|
+
if (generation !== this.lifecycleGeneration) return;
|
|
893
966
|
const applied = this.applyState(payload, 'push');
|
|
894
967
|
if (!applied) return;
|
|
895
968
|
// An identity-bound client tracks the pushed state but NEVER chases the
|
|
@@ -84,7 +84,28 @@ export interface AuthStateStore {
|
|
|
84
84
|
* session built on a secret that will not survive a reload.
|
|
85
85
|
*/
|
|
86
86
|
save(state: PersistedAuthState): Promise<boolean>;
|
|
87
|
+
/** Compare inside the storage queue before committing a proven shared holder. */
|
|
88
|
+
saveIfCurrent?(state: PersistedAuthState, guard: AuthStateWriteGuard): Promise<boolean>;
|
|
87
89
|
clear(): Promise<void>;
|
|
90
|
+
/** Durable local intent only: never authority or a mutation of an identity key. */
|
|
91
|
+
isAutomaticIdentitySignInSuppressed?(): Promise<boolean>;
|
|
92
|
+
setAutomaticIdentitySignInSuppressed?(suppressed: boolean, guard?: {
|
|
93
|
+
/** Checked inside the native store queue, before mutating logout intent. */
|
|
94
|
+
expectedState: PersistedAuthState;
|
|
95
|
+
isCurrent: () => boolean;
|
|
96
|
+
}): Promise<boolean>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AuthStateWriteGuard {
|
|
100
|
+
expectedState: PersistedAuthState | null;
|
|
101
|
+
isCurrent: () => boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function matchesAuthState(actual: PersistedAuthState | null, expected: PersistedAuthState | null): boolean {
|
|
105
|
+
if (!actual || !expected) return actual === expected;
|
|
106
|
+
return actual.deviceId === expected.deviceId && actual.deviceSecret === expected.deviceSecret &&
|
|
107
|
+
actual.sessionId === expected.sessionId && actual.userId === expected.userId &&
|
|
108
|
+
actual.accessToken === expected.accessToken && actual.expiresAt === expected.expiresAt;
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/**
|
|
@@ -115,6 +136,7 @@ export interface NativeKeyValueStorage {
|
|
|
115
136
|
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
116
137
|
*/
|
|
117
138
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
139
|
+
export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
118
140
|
|
|
119
141
|
/**
|
|
120
142
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
@@ -294,6 +316,11 @@ export function createMemoryAuthStateStore(): AuthStateStore {
|
|
|
294
316
|
current = state;
|
|
295
317
|
return true;
|
|
296
318
|
},
|
|
319
|
+
saveIfCurrent: async (state, guard) => {
|
|
320
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState)) return false;
|
|
321
|
+
current = state;
|
|
322
|
+
return true;
|
|
323
|
+
},
|
|
297
324
|
clear: async () => {
|
|
298
325
|
current = null;
|
|
299
326
|
},
|
|
@@ -428,8 +455,100 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
428
455
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
429
456
|
// must not silently lose the session for the app's lifetime.
|
|
430
457
|
let sessionMirror: PersistedAuthState | null | undefined;
|
|
458
|
+
let suppressedMirror: boolean | undefined;
|
|
459
|
+
let pending: Promise<void> = Promise.resolve();
|
|
460
|
+
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
461
|
+
const result = pending.then(operation);
|
|
462
|
+
pending = result.then(() => undefined, () => undefined);
|
|
463
|
+
return result;
|
|
464
|
+
};
|
|
465
|
+
const readCurrent = async (): Promise<PersistedAuthState | null> => {
|
|
466
|
+
if (sessionMirror !== undefined) return sessionMirror;
|
|
467
|
+
const [durable, warm] = await Promise.all([
|
|
468
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
469
|
+
]);
|
|
470
|
+
return composeState(durable, warm);
|
|
471
|
+
};
|
|
472
|
+
const persistState = async (state: PersistedAuthState): Promise<boolean> => {
|
|
473
|
+
sessionMirror = state;
|
|
474
|
+
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
475
|
+
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
476
|
+
// only reliable proof. The mirror keeps the session live for this app run,
|
|
477
|
+
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
478
|
+
// cold restart — surface it, never swallow.
|
|
479
|
+
let durablePersisted = false;
|
|
480
|
+
try {
|
|
481
|
+
const durableJson = serializeDurable(state);
|
|
482
|
+
await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
483
|
+
durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
|
|
484
|
+
if (!durablePersisted) {
|
|
485
|
+
logger.error(
|
|
486
|
+
'[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart',
|
|
487
|
+
undefined,
|
|
488
|
+
{ component: 'authStateStore' },
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
logger.error(
|
|
493
|
+
'[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
|
|
494
|
+
error,
|
|
495
|
+
{ component: 'authStateStore' },
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
499
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
500
|
+
// corrupt the durable write above.
|
|
501
|
+
try {
|
|
502
|
+
const warmJson = serializeWarmToken(state);
|
|
503
|
+
if (warmJson) {
|
|
504
|
+
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
505
|
+
} else {
|
|
506
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
507
|
+
}
|
|
508
|
+
} catch {
|
|
509
|
+
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
510
|
+
}
|
|
511
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
512
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
513
|
+
return durablePersisted;
|
|
514
|
+
};
|
|
431
515
|
return {
|
|
432
|
-
|
|
516
|
+
isAutomaticIdentitySignInSuppressed: () => serialize(async () => {
|
|
517
|
+
if (suppressedMirror !== undefined) return suppressedMirror;
|
|
518
|
+
try {
|
|
519
|
+
return (await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY)) !== null;
|
|
520
|
+
} catch {
|
|
521
|
+
return true; // Unknown storage must not start a new key sign-in.
|
|
522
|
+
}
|
|
523
|
+
}),
|
|
524
|
+
setAutomaticIdentitySignInSuppressed: (suppressed, guard) => serialize(async () => {
|
|
525
|
+
if (guard) {
|
|
526
|
+
let current = sessionMirror;
|
|
527
|
+
if (current === undefined) {
|
|
528
|
+
try {
|
|
529
|
+
const [durable, warm] = await Promise.all([
|
|
530
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
531
|
+
]);
|
|
532
|
+
current = composeState(durable, warm);
|
|
533
|
+
} catch { suppressedMirror = true; return false; }
|
|
534
|
+
}
|
|
535
|
+
const expected = guard.expectedState;
|
|
536
|
+
if (!guard.isCurrent() || !current ||
|
|
537
|
+
current.deviceId !== expected.deviceId || current.deviceSecret !== expected.deviceSecret ||
|
|
538
|
+
current.sessionId !== expected.sessionId || current.userId !== expected.userId ||
|
|
539
|
+
current.accessToken !== expected.accessToken || current.expiresAt !== expected.expiresAt) return false;
|
|
540
|
+
}
|
|
541
|
+
suppressedMirror = suppressed;
|
|
542
|
+
try {
|
|
543
|
+
if (suppressed) await storage.setItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, '1');
|
|
544
|
+
else await storage.removeItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
545
|
+
const actual = await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
546
|
+
const verified = suppressed ? actual === '1' : actual === null;
|
|
547
|
+
if (!verified) suppressedMirror = true;
|
|
548
|
+
return verified;
|
|
549
|
+
} catch { suppressedMirror = true; return false; }
|
|
550
|
+
}),
|
|
551
|
+
load: () => serialize(async () => {
|
|
433
552
|
if (sessionMirror !== undefined) {
|
|
434
553
|
return sessionMirror;
|
|
435
554
|
}
|
|
@@ -442,51 +561,16 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
442
561
|
} catch {
|
|
443
562
|
return null;
|
|
444
563
|
}
|
|
445
|
-
},
|
|
446
|
-
save:
|
|
447
|
-
|
|
448
|
-
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
449
|
-
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
450
|
-
// only reliable proof. The mirror keeps the session live for this app run,
|
|
451
|
-
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
452
|
-
// cold restart — surface it, never swallow.
|
|
453
|
-
let durablePersisted = false;
|
|
564
|
+
}),
|
|
565
|
+
save: (state) => serialize(() => persistState(state)),
|
|
566
|
+
saveIfCurrent: (state, guard) => serialize(async () => {
|
|
454
567
|
try {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
undefined,
|
|
462
|
-
{ component: 'authStateStore' },
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
} catch (error) {
|
|
466
|
-
logger.error(
|
|
467
|
-
'[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
|
|
468
|
-
error,
|
|
469
|
-
{ component: 'authStateStore' },
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
473
|
-
// durable credential re-mints a fresh token) and must never abort or
|
|
474
|
-
// corrupt the durable write above.
|
|
475
|
-
try {
|
|
476
|
-
const warmJson = serializeWarmToken(state);
|
|
477
|
-
if (warmJson) {
|
|
478
|
-
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
479
|
-
} else {
|
|
480
|
-
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
481
|
-
}
|
|
482
|
-
} catch {
|
|
483
|
-
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
484
|
-
}
|
|
485
|
-
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
486
|
-
// is intentionally excluded (it is a best-effort optimization).
|
|
487
|
-
return durablePersisted;
|
|
488
|
-
},
|
|
489
|
-
clear: async () => {
|
|
568
|
+
const current = await readCurrent();
|
|
569
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState)) return false;
|
|
570
|
+
} catch { return false; }
|
|
571
|
+
return persistState(state);
|
|
572
|
+
}),
|
|
573
|
+
clear: () => serialize(async () => {
|
|
490
574
|
sessionMirror = null;
|
|
491
575
|
try {
|
|
492
576
|
await storage.removeItem(AUTH_STATE_STORAGE_KEY);
|
|
@@ -498,6 +582,6 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
498
582
|
} catch {
|
|
499
583
|
// Non-fatal.
|
|
500
584
|
}
|
|
501
|
-
},
|
|
585
|
+
}),
|
|
502
586
|
};
|
|
503
587
|
}
|