@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
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ServerReputationApi = exports.ServerAgencyApi = exports.ServerLinkedAccountsApi = exports.ServerNotificationsApi = exports.ServerAssetsApi = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The server-side halves of the namespaces: what exists only with a service
|
|
6
|
+
* credential. `OxyServer` returns these from its namespace getters, so
|
|
7
|
+
* `server.assets.metadataByIds(...)` sits beside every client method of
|
|
8
|
+
* `oxy.assets`.
|
|
9
|
+
*/
|
|
10
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
4
11
|
const assets_1 = require("../api/assets");
|
|
5
12
|
const notifications_1 = require("../api/notifications");
|
|
6
13
|
const linkedAccounts_1 = require("../api/linkedAccounts");
|
|
@@ -185,7 +192,57 @@ class ServerLinkedAccountsApi extends linkedAccounts_1.LinkedAccountsApi {
|
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
exports.ServerLinkedAccountsApi = ServerLinkedAccountsApi;
|
|
195
|
+
function agencyAuthorityOptions(signal) {
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
|
|
198
|
+
}
|
|
188
199
|
class ServerAgencyApi extends agency_1.AgencyApi {
|
|
200
|
+
/** Live registry discovery with this service's existing capability scopes. */
|
|
201
|
+
async serviceCatalogs(input = {}) {
|
|
202
|
+
const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
|
|
203
|
+
const result = await lane(this.ctx).request('GET', path, undefined, agencyAuthorityOptions(input.signal));
|
|
204
|
+
return result.registrations;
|
|
205
|
+
}
|
|
206
|
+
/** Existing Oxy execution authority is required; this call creates none. */
|
|
207
|
+
async issueCapabilityTicket(input, options = {}) {
|
|
208
|
+
return lane(this.ctx).request('POST', '/capabilities/tickets', contracts_1.capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
|
|
209
|
+
}
|
|
210
|
+
/** Receiving app authenticates live introspection using its own service credential. */
|
|
211
|
+
async introspectCapabilityTicket(ticket, options = {}) {
|
|
212
|
+
if (ticket.trim() === '')
|
|
213
|
+
throw new Error('A capability ticket is required');
|
|
214
|
+
return lane(this.ctx).request('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Authenticates the existing user-authority route with the requester's bearer.
|
|
218
|
+
* The service bearer and attribution headers cannot substitute for that user.
|
|
219
|
+
* The token goes only to this client's configured Oxy authority; never retry.
|
|
220
|
+
*/
|
|
221
|
+
async createExecutionAuthorization(input, options) {
|
|
222
|
+
if (options.requesterToken === '' || /\s/.test(options.requesterToken))
|
|
223
|
+
throw new Error('A requester bearer is required');
|
|
224
|
+
const result = await this.ctx.request('POST', '/capabilities/execution-authorizations', input, {
|
|
225
|
+
...agencyAuthorityOptions(options.signal),
|
|
226
|
+
headers: { Authorization: `Bearer ${options.requesterToken}` },
|
|
227
|
+
});
|
|
228
|
+
return result.authorization;
|
|
229
|
+
}
|
|
230
|
+
/** Revoke a direct approval on its canonical requester-authenticated lane. */
|
|
231
|
+
async revokeExecutionAuthorization(authorizationId, options) {
|
|
232
|
+
options.signal?.throwIfAborted();
|
|
233
|
+
if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId))
|
|
234
|
+
throw new Error('An execution authorization id is required');
|
|
235
|
+
if (!options.requesterToken || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
|
|
236
|
+
throw new Error('A requester bearer is required');
|
|
237
|
+
await this.ctx.request('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`, undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
|
|
238
|
+
}
|
|
239
|
+
/** Independent service proof plus present requester bearer, sent only to Oxy. */
|
|
240
|
+
async createForegroundExecutionAuthorization(input, options) {
|
|
241
|
+
if (options.requesterToken === '' || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
|
|
242
|
+
throw new Error('A requester bearer is required');
|
|
243
|
+
const result = await lane(this.ctx).request('POST', '/capabilities/foreground-execution-authorizations', { ...contracts_1.foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken }, agencyAuthorityOptions(options.signal));
|
|
244
|
+
return result.authorization;
|
|
245
|
+
}
|
|
189
246
|
/**
|
|
190
247
|
* Product backend → Oxy: trade the signed-in requester's access token for a
|
|
191
248
|
* one-use assertion naming `agentId` (ADR 0025), authenticated with this
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = void 0;
|
|
4
|
+
exports.hasBoundedServiceTokenLifetime = hasBoundedServiceTokenLifetime;
|
|
5
|
+
/** Approved I03 ceiling shared by issuance and both JWT verifiers. */
|
|
6
|
+
exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
|
|
7
|
+
/** Call only after signature verification; cache checks alone confer no authority. */
|
|
8
|
+
function hasBoundedServiceTokenLifetime(claims, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
9
|
+
return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
|
|
10
|
+
&& typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
|
|
11
|
+
&& claims.iat <= nowSeconds && claims.exp > claims.iat
|
|
12
|
+
&& claims.exp - claims.iat <= exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
|
|
13
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.SessionClient = void 0;
|
|
|
4
4
|
const contracts_1 = require("@oxy.so/contracts");
|
|
5
5
|
const logger_1 = require("../logger");
|
|
6
6
|
const cacheKey_1 = require("../utils/cacheKey");
|
|
7
|
+
const tokenClaims_1 = require("../utils/tokenClaims");
|
|
7
8
|
const deviceDirectory_1 = require("./deviceDirectory");
|
|
8
9
|
const socketLoader_1 = require("./socketLoader");
|
|
9
10
|
/**
|
|
@@ -37,6 +38,8 @@ class SessionClient {
|
|
|
37
38
|
this.socket = null;
|
|
38
39
|
this.tokenUnsub = null;
|
|
39
40
|
this.started = false;
|
|
41
|
+
this.lifecycleGeneration = 0;
|
|
42
|
+
this.fullSignOutPersistence = null;
|
|
40
43
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
41
44
|
this.channel = null;
|
|
42
45
|
/** App-facing subscriptions to named server-pushed socket events. */
|
|
@@ -44,6 +47,35 @@ class SessionClient {
|
|
|
44
47
|
/** Event names already bound on the CURRENT socket instance. */
|
|
45
48
|
this.boundServerEvents = new Set();
|
|
46
49
|
}
|
|
50
|
+
persistFullSignOut() {
|
|
51
|
+
const state = this.state;
|
|
52
|
+
if (!state || !this.options.onFullExplicitSignOut)
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
if (this.fullSignOutPersistence?.state === state)
|
|
55
|
+
return this.fullSignOutPersistence.pending;
|
|
56
|
+
const pending = this.options.onFullExplicitSignOut();
|
|
57
|
+
this.fullSignOutPersistence = { state, pending };
|
|
58
|
+
void pending.catch(() => {
|
|
59
|
+
if (this.fullSignOutPersistence?.pending === pending)
|
|
60
|
+
this.fullSignOutPersistence = null;
|
|
61
|
+
});
|
|
62
|
+
return pending;
|
|
63
|
+
}
|
|
64
|
+
async requestDevice(method, url, data, requestOptions) {
|
|
65
|
+
const generation = this.lifecycleGeneration;
|
|
66
|
+
const response = await this.host.makeRequest(method, url, data, requestOptions);
|
|
67
|
+
if (generation !== this.lifecycleGeneration)
|
|
68
|
+
throw new Error('Device session request was superseded');
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
72
|
+
resetLocalState() {
|
|
73
|
+
this.lifecycleGeneration += 1;
|
|
74
|
+
this.stop();
|
|
75
|
+
this.state = null;
|
|
76
|
+
this.directory = null;
|
|
77
|
+
this.notify();
|
|
78
|
+
}
|
|
47
79
|
getState() {
|
|
48
80
|
return this.state;
|
|
49
81
|
}
|
|
@@ -166,6 +198,7 @@ class SessionClient {
|
|
|
166
198
|
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
167
199
|
*/
|
|
168
200
|
applyState(raw, origin = 'push', activeToken) {
|
|
201
|
+
const generation = this.lifecycleGeneration;
|
|
169
202
|
const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
|
|
170
203
|
if (!next) {
|
|
171
204
|
logger_1.logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -221,6 +254,8 @@ class SessionClient {
|
|
|
221
254
|
next.accounts.length > 0 &&
|
|
222
255
|
(activeAccountId === null || (0, cacheKey_1.computeIdentityTag)(this.host.getAccessToken()) !== activeAccountId);
|
|
223
256
|
const publish = () => {
|
|
257
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
258
|
+
return;
|
|
224
259
|
this.notify();
|
|
225
260
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
226
261
|
try {
|
|
@@ -239,6 +274,8 @@ class SessionClient {
|
|
|
239
274
|
// (and this stays synchronous) for every client that never read a
|
|
240
275
|
// directory, i.e. the whole account lane.
|
|
241
276
|
const finishApply = () => {
|
|
277
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
278
|
+
return;
|
|
242
279
|
const settling = this.settleDirectory(next);
|
|
243
280
|
if (settling === null) {
|
|
244
281
|
publish();
|
|
@@ -246,8 +283,38 @@ class SessionClient {
|
|
|
246
283
|
}
|
|
247
284
|
void settling.then(publish);
|
|
248
285
|
};
|
|
286
|
+
// A sibling's validated full removal is logout here too. Persist the local
|
|
287
|
+
// account-mode recovery suppression before publishing signed-out or running
|
|
288
|
+
// the empty-state refresh (which could otherwise challenge Commons again).
|
|
289
|
+
// Keep the push credential intact; a marker is not a destructive wipe.
|
|
290
|
+
// Unknown initial empty state, stale revisions and identity pins do not
|
|
291
|
+
// turn into logout intent. A newer state/lifecycle suppresses this publish.
|
|
292
|
+
const priorBearer = this.host.getAccessToken();
|
|
293
|
+
const bearerAccount = priorBearer ? (0, tokenClaims_1.decodeTokenClaims)(priorBearer)?.userId : null;
|
|
294
|
+
// Warm cold boot may fetch authoritative empty state before it ever had a
|
|
295
|
+
// local projection. Its authenticated holder is history too. Decoding only
|
|
296
|
+
// identifies local history; authority remains the validated REST response.
|
|
297
|
+
const warmHolderEnded = previousState === null && origin === 'request' &&
|
|
298
|
+
typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
|
|
299
|
+
this.host.getDeviceCredential()?.deviceId === next.deviceId;
|
|
300
|
+
if (((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
|
|
301
|
+
next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut) {
|
|
302
|
+
try {
|
|
303
|
+
void this.persistFullSignOut().then(finishApply).catch((error) => {
|
|
304
|
+
logger_1.logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
305
|
+
finishApply();
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
logger_1.logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
310
|
+
finishApply();
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
249
314
|
if (needsMintBeforeNotify) {
|
|
250
315
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
316
|
+
if (generation !== this.lifecycleGeneration)
|
|
317
|
+
return;
|
|
251
318
|
logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
252
319
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
253
320
|
// state so subscribers keep observing the account whose token is planted.
|
|
@@ -271,7 +338,7 @@ class SessionClient {
|
|
|
271
338
|
* the token still needs to be planted. The account-match guard rejects a stale response for an
|
|
272
339
|
* account that is no longer active.
|
|
273
340
|
*/
|
|
274
|
-
applySync(raw) {
|
|
341
|
+
async applySync(raw) {
|
|
275
342
|
const sync = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionSyncSchema, raw);
|
|
276
343
|
if (!sync) {
|
|
277
344
|
const parsed = contracts_1.deviceSessionSyncSchema.safeParse(raw);
|
|
@@ -288,6 +355,8 @@ class SessionClient {
|
|
|
288
355
|
return;
|
|
289
356
|
}
|
|
290
357
|
this.commitSync(sync);
|
|
358
|
+
if (this.fullSignOutPersistence?.state === this.state)
|
|
359
|
+
await this.fullSignOutPersistence.pending;
|
|
291
360
|
}
|
|
292
361
|
/**
|
|
293
362
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
@@ -365,7 +434,7 @@ class SessionClient {
|
|
|
365
434
|
}
|
|
366
435
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
367
436
|
async fetchDirectory() {
|
|
368
|
-
const res = await this.
|
|
437
|
+
const res = await this.requestDevice('GET', '/session/device/directory', undefined, { cache: false });
|
|
369
438
|
return this.applyDirectory(res);
|
|
370
439
|
}
|
|
371
440
|
/**
|
|
@@ -474,8 +543,8 @@ class SessionClient {
|
|
|
474
543
|
}
|
|
475
544
|
}
|
|
476
545
|
async bootstrap() {
|
|
477
|
-
const res = await this.
|
|
478
|
-
this.applySync(res);
|
|
546
|
+
const res = await this.requestDevice('GET', '/session/device/state', undefined, { cache: false });
|
|
547
|
+
await this.applySync(res);
|
|
479
548
|
}
|
|
480
549
|
/**
|
|
481
550
|
* Adopt server-authoritative state already returned by another session
|
|
@@ -518,7 +587,7 @@ class SessionClient {
|
|
|
518
587
|
* compatibility path for callers still keyed on account ids.
|
|
519
588
|
*/
|
|
520
589
|
async activateContext(contextId) {
|
|
521
|
-
const res = await this.
|
|
590
|
+
const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
522
591
|
const activation = (0, contracts_1.safeParseContract)(contracts_1.deviceActivateResponseSchema, res);
|
|
523
592
|
if (!activation) {
|
|
524
593
|
logger_1.logger.warn('[SessionClient] discarded invalid activation response');
|
|
@@ -544,8 +613,8 @@ class SessionClient {
|
|
|
544
613
|
}
|
|
545
614
|
}
|
|
546
615
|
async switchAccount(accountId) {
|
|
547
|
-
const res = await this.
|
|
548
|
-
this.applySync(res);
|
|
616
|
+
const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
617
|
+
await this.applySync(res);
|
|
549
618
|
this.postCommitPing();
|
|
550
619
|
}
|
|
551
620
|
/**
|
|
@@ -560,9 +629,11 @@ class SessionClient {
|
|
|
560
629
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
561
630
|
*/
|
|
562
631
|
async signOut(target) {
|
|
563
|
-
const res = await this.
|
|
564
|
-
this.applySync(res);
|
|
632
|
+
const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
|
|
633
|
+
await this.applySync(res);
|
|
565
634
|
this.postCommitPing();
|
|
635
|
+
if (this.state?.accounts.length === 0)
|
|
636
|
+
await this.persistFullSignOut();
|
|
566
637
|
}
|
|
567
638
|
/**
|
|
568
639
|
* Remove ONE `principal → account` pair, and only that pair.
|
|
@@ -601,7 +672,7 @@ class SessionClient {
|
|
|
601
672
|
* applied this revision.
|
|
602
673
|
*/
|
|
603
674
|
async removeFromDevice(target) {
|
|
604
|
-
const res = await this.
|
|
675
|
+
const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
|
|
605
676
|
const removal = (0, contracts_1.safeParseContract)(contracts_1.deviceDirectorySyncSchema, res);
|
|
606
677
|
if (!removal) {
|
|
607
678
|
logger_1.logger.warn('[SessionClient] discarded invalid device removal response');
|
|
@@ -609,6 +680,8 @@ class SessionClient {
|
|
|
609
680
|
}
|
|
610
681
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
611
682
|
const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
|
|
683
|
+
if (this.fullSignOutPersistence?.state === this.state)
|
|
684
|
+
await this.fullSignOutPersistence.pending;
|
|
612
685
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
613
686
|
// directory did — a socket push already applied this revision — the
|
|
614
687
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -616,10 +689,12 @@ class SessionClient {
|
|
|
616
689
|
this.notify();
|
|
617
690
|
}
|
|
618
691
|
this.postCommitPing();
|
|
692
|
+
if (this.state?.accounts.length === 0)
|
|
693
|
+
await this.persistFullSignOut();
|
|
619
694
|
}
|
|
620
695
|
async addCurrentAccount() {
|
|
621
|
-
const res = await this.
|
|
622
|
-
this.applySync(res);
|
|
696
|
+
const res = await this.requestDevice('POST', '/session/device/add', undefined, { cache: false });
|
|
697
|
+
await this.applySync(res);
|
|
623
698
|
this.postCommitPing();
|
|
624
699
|
}
|
|
625
700
|
/**
|
|
@@ -721,6 +796,7 @@ class SessionClient {
|
|
|
721
796
|
const deviceCredential = this.host.getDeviceCredential();
|
|
722
797
|
if (!token && !deviceCredential)
|
|
723
798
|
return;
|
|
799
|
+
const generation = this.lifecycleGeneration;
|
|
724
800
|
const socket = io(this.host.getBaseURL(), {
|
|
725
801
|
transports: ['websocket'],
|
|
726
802
|
autoConnect: true,
|
|
@@ -745,6 +821,8 @@ class SessionClient {
|
|
|
745
821
|
socket.on('session_state', (payload) => {
|
|
746
822
|
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
747
823
|
// empty state here must not erase the durable device credential.
|
|
824
|
+
if (generation !== this.lifecycleGeneration)
|
|
825
|
+
return;
|
|
748
826
|
const applied = this.applyState(payload, 'push');
|
|
749
827
|
if (!applied)
|
|
750
828
|
return;
|
|
@@ -18,11 +18,19 @@
|
|
|
18
18
|
* ESM-safe (no `require()`).
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
|
|
21
|
+
exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
|
|
22
|
+
exports.matchesAuthState = matchesAuthState;
|
|
22
23
|
exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
|
|
23
24
|
exports.createWebAuthStateStore = createWebAuthStateStore;
|
|
24
25
|
exports.createNativeAuthStateStore = createNativeAuthStateStore;
|
|
25
26
|
const logger_1 = require("../logger");
|
|
27
|
+
function matchesAuthState(actual, expected) {
|
|
28
|
+
if (!actual || !expected)
|
|
29
|
+
return actual === expected;
|
|
30
|
+
return actual.deviceId === expected.deviceId && actual.deviceSecret === expected.deviceSecret &&
|
|
31
|
+
actual.sessionId === expected.sessionId && actual.userId === expected.userId &&
|
|
32
|
+
actual.accessToken === expected.accessToken && actual.expiresAt === expected.expiresAt;
|
|
33
|
+
}
|
|
26
34
|
/**
|
|
27
35
|
* Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
|
|
28
36
|
* (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
|
|
@@ -41,6 +49,7 @@ const logger_1 = require("../logger");
|
|
|
41
49
|
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
42
50
|
*/
|
|
43
51
|
exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
52
|
+
exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
44
53
|
/**
|
|
45
54
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
46
55
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -204,6 +213,12 @@ function createMemoryAuthStateStore() {
|
|
|
204
213
|
current = state;
|
|
205
214
|
return true;
|
|
206
215
|
},
|
|
216
|
+
saveIfCurrent: async (state, guard) => {
|
|
217
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
|
|
218
|
+
return false;
|
|
219
|
+
current = state;
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
207
222
|
clear: async () => {
|
|
208
223
|
current = null;
|
|
209
224
|
},
|
|
@@ -331,8 +346,110 @@ function createNativeAuthStateStore(storage) {
|
|
|
331
346
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
332
347
|
// must not silently lose the session for the app's lifetime.
|
|
333
348
|
let sessionMirror;
|
|
349
|
+
let suppressedMirror;
|
|
350
|
+
let pending = Promise.resolve();
|
|
351
|
+
const serialize = (operation) => {
|
|
352
|
+
const result = pending.then(operation);
|
|
353
|
+
pending = result.then(() => undefined, () => undefined);
|
|
354
|
+
return result;
|
|
355
|
+
};
|
|
356
|
+
const readCurrent = async () => {
|
|
357
|
+
if (sessionMirror !== undefined)
|
|
358
|
+
return sessionMirror;
|
|
359
|
+
const [durable, warm] = await Promise.all([
|
|
360
|
+
storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
361
|
+
]);
|
|
362
|
+
return composeState(durable, warm);
|
|
363
|
+
};
|
|
364
|
+
const persistState = async (state) => {
|
|
365
|
+
sessionMirror = state;
|
|
366
|
+
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
367
|
+
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
368
|
+
// only reliable proof. The mirror keeps the session live for this app run,
|
|
369
|
+
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
370
|
+
// cold restart — surface it, never swallow.
|
|
371
|
+
let durablePersisted = false;
|
|
372
|
+
try {
|
|
373
|
+
const durableJson = serializeDurable(state);
|
|
374
|
+
await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
|
|
375
|
+
durablePersisted = (await storage.getItem(exports.AUTH_STATE_STORAGE_KEY)) === durableJson;
|
|
376
|
+
if (!durablePersisted) {
|
|
377
|
+
logger_1.logger.error('[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', undefined, { component: 'authStateStore' });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
logger_1.logger.error('[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', error, { component: 'authStateStore' });
|
|
382
|
+
}
|
|
383
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
384
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
385
|
+
// corrupt the durable write above.
|
|
386
|
+
try {
|
|
387
|
+
const warmJson = serializeWarmToken(state);
|
|
388
|
+
if (warmJson) {
|
|
389
|
+
await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
397
|
+
}
|
|
398
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
399
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
400
|
+
return durablePersisted;
|
|
401
|
+
};
|
|
334
402
|
return {
|
|
335
|
-
|
|
403
|
+
isAutomaticIdentitySignInSuppressed: () => serialize(async () => {
|
|
404
|
+
if (suppressedMirror !== undefined)
|
|
405
|
+
return suppressedMirror;
|
|
406
|
+
try {
|
|
407
|
+
return (await storage.getItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY)) !== null;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return true; // Unknown storage must not start a new key sign-in.
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
setAutomaticIdentitySignInSuppressed: (suppressed, guard) => serialize(async () => {
|
|
414
|
+
if (guard) {
|
|
415
|
+
let current = sessionMirror;
|
|
416
|
+
if (current === undefined) {
|
|
417
|
+
try {
|
|
418
|
+
const [durable, warm] = await Promise.all([
|
|
419
|
+
storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
420
|
+
]);
|
|
421
|
+
current = composeState(durable, warm);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
suppressedMirror = true;
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const expected = guard.expectedState;
|
|
429
|
+
if (!guard.isCurrent() || !current ||
|
|
430
|
+
current.deviceId !== expected.deviceId || current.deviceSecret !== expected.deviceSecret ||
|
|
431
|
+
current.sessionId !== expected.sessionId || current.userId !== expected.userId ||
|
|
432
|
+
current.accessToken !== expected.accessToken || current.expiresAt !== expected.expiresAt)
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
suppressedMirror = suppressed;
|
|
436
|
+
try {
|
|
437
|
+
if (suppressed)
|
|
438
|
+
await storage.setItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, '1');
|
|
439
|
+
else
|
|
440
|
+
await storage.removeItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
441
|
+
const actual = await storage.getItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
442
|
+
const verified = suppressed ? actual === '1' : actual === null;
|
|
443
|
+
if (!verified)
|
|
444
|
+
suppressedMirror = true;
|
|
445
|
+
return verified;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
suppressedMirror = true;
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}),
|
|
452
|
+
load: () => serialize(async () => {
|
|
336
453
|
if (sessionMirror !== undefined) {
|
|
337
454
|
return sessionMirror;
|
|
338
455
|
}
|
|
@@ -346,46 +463,20 @@ function createNativeAuthStateStore(storage) {
|
|
|
346
463
|
catch {
|
|
347
464
|
return null;
|
|
348
465
|
}
|
|
349
|
-
},
|
|
350
|
-
save:
|
|
351
|
-
|
|
352
|
-
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
353
|
-
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
354
|
-
// only reliable proof. The mirror keeps the session live for this app run,
|
|
355
|
-
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
356
|
-
// cold restart — surface it, never swallow.
|
|
357
|
-
let durablePersisted = false;
|
|
466
|
+
}),
|
|
467
|
+
save: (state) => serialize(() => persistState(state)),
|
|
468
|
+
saveIfCurrent: (state, guard) => serialize(async () => {
|
|
358
469
|
try {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (!durablePersisted) {
|
|
363
|
-
logger_1.logger.error('[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', undefined, { component: 'authStateStore' });
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
catch (error) {
|
|
367
|
-
logger_1.logger.error('[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', error, { component: 'authStateStore' });
|
|
368
|
-
}
|
|
369
|
-
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
370
|
-
// durable credential re-mints a fresh token) and must never abort or
|
|
371
|
-
// corrupt the durable write above.
|
|
372
|
-
try {
|
|
373
|
-
const warmJson = serializeWarmToken(state);
|
|
374
|
-
if (warmJson) {
|
|
375
|
-
await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
379
|
-
}
|
|
470
|
+
const current = await readCurrent();
|
|
471
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
|
|
472
|
+
return false;
|
|
380
473
|
}
|
|
381
474
|
catch {
|
|
382
|
-
|
|
475
|
+
return false;
|
|
383
476
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
},
|
|
388
|
-
clear: async () => {
|
|
477
|
+
return persistState(state);
|
|
478
|
+
}),
|
|
479
|
+
clear: () => serialize(async () => {
|
|
389
480
|
sessionMirror = null;
|
|
390
481
|
try {
|
|
391
482
|
await storage.removeItem(exports.AUTH_STATE_STORAGE_KEY);
|
|
@@ -399,6 +490,6 @@ function createNativeAuthStateStore(storage) {
|
|
|
399
490
|
catch {
|
|
400
491
|
// Non-fatal.
|
|
401
492
|
}
|
|
402
|
-
},
|
|
493
|
+
}),
|
|
403
494
|
};
|
|
404
495
|
}
|