@oxy.so/core 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/cjs/HttpService.js +142 -33
  2. package/dist/cjs/api/accounts.js +49 -10
  3. package/dist/cjs/api/apps.js +2 -2
  4. package/dist/cjs/api/auth.js +24 -3
  5. package/dist/cjs/api/billing.js +35 -0
  6. package/dist/cjs/boot/sessionColdBoot.js +28 -57
  7. package/dist/cjs/inference/OxyInferenceClient.js +34 -15
  8. package/dist/cjs/server/OxyServer.js +107 -51
  9. package/dist/cjs/server/agentAccount.js +19 -0
  10. package/dist/cjs/server/auth.js +19 -0
  11. package/dist/cjs/server/capabilityTicket.js +3 -41
  12. package/dist/cjs/server/index.js +10 -2
  13. package/dist/cjs/server/liveCapabilityTicket.js +48 -0
  14. package/dist/cjs/server/middleware.js +60 -18
  15. package/dist/cjs/server/namespaces.js +57 -0
  16. package/dist/cjs/server/serviceTokenLifetime.js +13 -0
  17. package/dist/cjs/session/SessionClient.js +90 -12
  18. package/dist/cjs/session/authStateStore.js +130 -39
  19. package/dist/cjs/session/refresh.js +152 -30
  20. package/dist/cjs/session/sharedDeviceCredential.js +17 -1
  21. package/dist/cjs/utils/officialOrigins.js +7 -18
  22. package/dist/esm/HttpService.js +143 -34
  23. package/dist/esm/api/accounts.js +38 -0
  24. package/dist/esm/api/apps.js +2 -2
  25. package/dist/esm/api/auth.js +23 -3
  26. package/dist/esm/api/billing.js +35 -0
  27. package/dist/esm/boot/sessionColdBoot.js +29 -58
  28. package/dist/esm/inference/OxyInferenceClient.js +32 -13
  29. package/dist/esm/server/OxyServer.js +107 -51
  30. package/dist/esm/server/agentAccount.js +16 -0
  31. package/dist/esm/server/auth.js +18 -0
  32. package/dist/esm/server/capabilityTicket.js +1 -39
  33. package/dist/esm/server/index.js +4 -1
  34. package/dist/esm/server/liveCapabilityTicket.js +45 -0
  35. package/dist/esm/server/middleware.js +60 -18
  36. package/dist/esm/server/namespaces.js +57 -0
  37. package/dist/esm/server/serviceTokenLifetime.js +9 -0
  38. package/dist/esm/session/SessionClient.js +90 -12
  39. package/dist/esm/session/authStateStore.js +128 -38
  40. package/dist/esm/session/refresh.js +151 -30
  41. package/dist/esm/session/sharedDeviceCredential.js +17 -1
  42. package/dist/esm/utils/officialOrigins.js +2 -13
  43. package/dist/types/HttpService.d.ts +26 -12
  44. package/dist/types/api/accounts.d.ts +38 -0
  45. package/dist/types/api/agency.d.ts +3 -1
  46. package/dist/types/api/apps.d.ts +3 -1
  47. package/dist/types/api/auth.d.ts +12 -1
  48. package/dist/types/api/billing.d.ts +12 -0
  49. package/dist/types/api/session.d.ts +3 -1
  50. package/dist/types/boot/sessionColdBoot.d.ts +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/inference/OxyInferenceClient.d.ts +18 -0
  53. package/dist/types/inference/index.d.ts +1 -1
  54. package/dist/types/server/OxyServer.d.ts +16 -9
  55. package/dist/types/server/agentAccount.d.ts +14 -0
  56. package/dist/types/server/auth.d.ts +18 -0
  57. package/dist/types/server/capabilityTicket.d.ts +2 -4
  58. package/dist/types/server/index.d.ts +7 -1
  59. package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
  60. package/dist/types/server/middleware.d.ts +15 -6
  61. package/dist/types/server/namespaces.d.ts +78 -2
  62. package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
  63. package/dist/types/session/SessionClient.d.ts +8 -0
  64. package/dist/types/session/authStateStore.d.ts +15 -0
  65. package/dist/types/session/index.d.ts +1 -1
  66. package/dist/types/session/refresh.d.ts +32 -7
  67. package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
  68. package/dist/types/utils/officialOrigins.d.ts +1 -5
  69. package/package.json +6 -6
  70. package/src/HttpService.ts +138 -50
  71. package/src/api/accounts.ts +22 -0
  72. package/src/api/agency.ts +3 -1
  73. package/src/api/apps.ts +2 -2
  74. package/src/api/auth.ts +31 -2
  75. package/src/api/billing.ts +71 -9
  76. package/src/api/session.ts +3 -1
  77. package/src/boot/sessionColdBoot.ts +27 -72
  78. package/src/index.ts +1 -1
  79. package/src/inference/OxyInferenceClient.ts +35 -5
  80. package/src/inference/index.ts +1 -0
  81. package/src/server/OxyServer.ts +114 -53
  82. package/src/server/agentAccount.ts +28 -0
  83. package/src/server/auth.ts +25 -0
  84. package/src/server/capabilityTicket.ts +2 -42
  85. package/src/server/index.ts +13 -0
  86. package/src/server/liveCapabilityTicket.ts +47 -0
  87. package/src/server/middleware.ts +72 -25
  88. package/src/server/namespaces.ts +99 -2
  89. package/src/server/serviceTokenLifetime.ts +13 -0
  90. package/src/session/SessionClient.ts +85 -12
  91. package/src/session/authStateStore.ts +130 -46
  92. package/src/session/index.ts +1 -0
  93. package/src/session/refresh.ts +144 -28
  94. package/src/session/sharedDeviceCredential.ts +14 -2
  95. package/src/utils/officialOrigins.ts +2 -13
@@ -24,7 +24,8 @@
24
24
  import type { DeviceSessionState, DeviceTokenMintResponse } from '@oxy.so/contracts';
25
25
  import type { OxyServices } from '../OxyServices';
26
26
  import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
27
- import type { AuthStateStore, PersistedAuthState } from './authStateStore';
27
+ import { matchesAuthState, type AuthStateStore, type PersistedAuthState } from './authStateStore';
28
+ import type { SharedDeviceCredentialStore } from './sharedDeviceCredential';
28
29
  import type { IdentityPin } from './identityPin';
29
30
  import { establishIdentitySession, resolveIdentityPin, type IdentityBinding } from './identitySession';
30
31
  import { isNative } from '../utils/platform';
@@ -99,6 +100,10 @@ export interface RefreshDeps {
99
100
  * Commons-identity one.
100
101
  */
101
102
  identity?: IdentityBinding;
103
+ /** Native ACCOUNT join point. A live local holder always wins. */
104
+ sharedDeviceCredential?: SharedDeviceCredentialStore;
105
+ /** A replaced/unmounted provider cannot commit a pending refresh. */
106
+ isCurrent?: () => boolean;
102
107
  }
103
108
 
104
109
  /**
@@ -148,9 +153,13 @@ export type DeviceSecretMintOutcome =
148
153
  *
149
154
  * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
150
155
  * the access token; a failed durable persist yields `persist-failed` WITHOUT
151
- * planting. This function performs NO store mutation on failure — the caller
156
+ * planting. Apart from the account-mode logout marker below, failure mutations
157
+ * belong to the caller, which
152
158
  * applies the drop/clear policy (which differs web vs native) from the returned
153
- * status.
159
+ * status. A native account store with a suppression marker records a current
160
+ * `no_active_session`, or an invalid secret with prior authenticated session
161
+ * history, records durable ACCOUNT logout intent; it never deletes that holder.
162
+ * Error classification uses the existing 401 message markers.
154
163
  *
155
164
  * `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
156
165
  * `accountId` (so the server mints that account's token without touching
@@ -163,10 +172,13 @@ export async function refreshDeviceSecretArm(deps: {
163
172
  store: AuthStateStore;
164
173
  /** The identity pin, when this client is identity-bound. */
165
174
  pin?: IdentityPin | null;
175
+ isCurrent?: () => boolean;
166
176
  }): Promise<DeviceSecretMintOutcome> {
167
177
  const { oxy, store } = deps;
168
178
  const pin = deps.pin ?? null;
179
+ const isCurrent = deps.isCurrent ?? (() => true);
169
180
  return oxy.http.runSingleFlightDeviceSecretMint(async () => {
181
+ if (!isCurrent()) return {status: 'session-ended'};
170
182
  const epoch = oxy.http.getSessionEpoch();
171
183
  const persisted = await store.load();
172
184
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
@@ -208,8 +220,29 @@ export async function refreshDeviceSecretArm(deps: {
208
220
  // transient and KEEP the credential so a later attempt self-heals. Wiping the
209
221
  // credential on an ambiguous 401 is what logged users out on every deploy,
210
222
  // ecosystem-wide.
211
- if (body.includes('invalid_device_secret')) return { status: 'invalid-secret' };
212
- if (body.includes('no_active_session')) return { status: 'no-session' };
223
+ const invalidSecret = body.includes('invalid_device_secret');
224
+ const noSession = body.includes('no_active_session');
225
+ if (invalidSecret || noSession) {
226
+ // A stopped ACCOUNT sibling cannot receive the other app's logout
227
+ // push. Its exact live holder's authoritative empty-device rejection
228
+ // must suppress automatic key recovery on this and future boots.
229
+ // A late rejection for a replaced session/local epoch is not current.
230
+ if (pin === null && store.setAutomaticIdentitySignInSuppressed && (!invalidSecret || (persisted.sessionId && persisted.userId))) {
231
+ const current = await store.load();
232
+ if (
233
+ !isCurrent() || oxy.http.getSessionEpoch() !== epoch ||
234
+ current?.deviceId !== persisted.deviceId ||
235
+ current.deviceSecret !== persisted.deviceSecret ||
236
+ current.sessionId !== persisted.sessionId ||
237
+ current.userId !== persisted.userId ||
238
+ current.accessToken !== persisted.accessToken
239
+ ) return { status: 'session-ended' };
240
+ if (!(await store.setAutomaticIdentitySignInSuppressed(true, {
241
+ expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch,
242
+ }))) return { status: 'session-ended' };
243
+ }
244
+ return { status: invalidSecret ? 'invalid-secret' : 'no-session' };
245
+ }
213
246
  // A pinned mint whose account left the device set. The secret is intact —
214
247
  // never classify this as a bad secret, or the caller would drop a healthy
215
248
  // credential over a stale identity binding.
@@ -231,24 +264,32 @@ export async function refreshDeviceSecretArm(deps: {
231
264
  expiresAt: mint.expiresAt,
232
265
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
233
266
  };
267
+ if (!isCurrent()) return {status: 'session-ended'};
234
268
  if (oxy.http.getSessionEpoch() !== epoch) {
235
- // Signed out while minting. A store that still holds the presented
236
- // secret takes the server's `nextDeviceSecret` (the token-null lane keeps
237
- // the store so a reload can restore); a store the sign-out cleared must
238
- // NOT be refilled. Either way, plant nothing.
269
+ // A logout or account/session switch invalidated this mint. Preserve
270
+ // only credential rotation for an otherwise unchanged retained context;
271
+ // never transplant the old mint's account, session or bearer. Compare
272
+ // the entire snapshot inside the store queue: same holder bytes alone
273
+ // cannot distinguish an account switch, nor a queued save of that switch.
274
+ const retainedEpoch = oxy.http.getSessionEpoch();
239
275
  const current = await store.load();
240
- if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
241
- await store.save(next);
276
+ if (store.saveIfCurrent && current && matchesAuthState(current, persisted)) {
277
+ await store.saveIfCurrent({...current, deviceSecret: mint.nextDeviceSecret}, {
278
+ expectedState: current,
279
+ isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === retainedEpoch,
280
+ });
242
281
  }
243
282
  return { status: 'session-ended' };
244
283
  }
245
284
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
246
285
  // A failed durable persist must NOT plant.
247
- const persistedOk = await store.save(next);
286
+ const persistedOk = store.saveIfCurrent
287
+ ? await store.saveIfCurrent(next, {expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch})
288
+ : await store.save(next);
248
289
  if (!persistedOk) {
249
290
  return { status: 'persist-failed' };
250
291
  }
251
- if (oxy.http.getSessionEpoch() !== epoch) {
292
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) {
252
293
  return { status: 'session-ended' };
253
294
  }
254
295
  oxy.session.setAccessToken(mint.accessToken);
@@ -262,15 +303,72 @@ export async function refreshDeviceSecretArm(deps: {
262
303
  });
263
304
  }
264
305
 
306
+ /**
307
+ * Prove a shared native ACCOUNT holder before replacing local bytes. Callers
308
+ * may replace a local credential only after its explicit mint rejection. The
309
+ * optional guarded storage seam makes custom stores fail closed on adoption.
310
+ * This lane never clears explicit sign-out intent or uses an identity key.
311
+ */
312
+ export async function refreshSharedDeviceArm(deps: {
313
+ oxy: OxyServices;
314
+ store: AuthStateStore;
315
+ shared: SharedDeviceCredentialStore;
316
+ rejectedLocalHolder?: boolean;
317
+ isCurrent?: () => boolean;
318
+ }): Promise<DeviceSecretMintOutcome> {
319
+ const { oxy, store, shared } = deps;
320
+ const isCurrent = deps.isCurrent ?? (() => true);
321
+ return oxy.http.runSingleFlightDeviceSecretMint(async () => {
322
+ const epoch = oxy.http.getSessionEpoch();
323
+ if (!isCurrent()) return {status: 'session-ended'};
324
+ const before = await store.load();
325
+ if (before?.deviceId && before.deviceSecret && !deps.rejectedLocalHolder) return { status: 'no-secret' };
326
+ const read = await shared.read();
327
+ if (read.state !== 'present' || !store.saveIfCurrent) return { status: 'no-secret' };
328
+ const credential = read.credential;
329
+ if (credential.deviceId === before?.deviceId && credential.deviceSecret === before.deviceSecret) return { status: 'no-secret' };
330
+ let mint: DeviceTokenMintResponse;
331
+ try {
332
+ mint = await oxy.devices.mintToken(credential.deviceId, credential.deviceSecret);
333
+ } catch (error) {
334
+ if (extractErrorStatus(error) === 429) oxy.http.noteRefreshRateLimited();
335
+ if (extractErrorStatus(error) === 401) {
336
+ const message = (error as {message?: unknown})?.message;
337
+ if (typeof message === 'string' && message.includes('invalid_device_secret')) {
338
+ // There is no cross-app CAS clear in the bridge contract. An old
339
+ // rejection cannot delete a slot another app has since published.
340
+ return {status: 'invalid-secret'};
341
+ }
342
+ if (typeof message === 'string' && message.includes('no_active_session')) return {status: 'no-session'};
343
+ }
344
+ return {status: 'transient'};
345
+ }
346
+ const bound = mint.state.accounts.find(account => account.accountId === mint.state.activeAccountId);
347
+ if (!bound || mint.state.deviceId !== credential.deviceId || !mint.accessToken || !mint.nextDeviceSecret) return {status: 'transient'};
348
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch || !matchesAuthState(await store.load(), before)) return {status: 'session-ended'};
349
+ const next: PersistedAuthState = {
350
+ deviceId: mint.state.deviceId, deviceSecret: mint.nextDeviceSecret,
351
+ sessionId: bound.sessionId, userId: bound.accountId,
352
+ accessToken: mint.accessToken, expiresAt: mint.expiresAt,
353
+ };
354
+ if (!(await store.saveIfCurrent(next, {expectedState: before, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch}))) return {status: 'persist-failed'};
355
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) return {status: 'session-ended'};
356
+ oxy.session.setAccessToken(mint.accessToken);
357
+ return {status: 'ok', token: mint.accessToken, sessionId: bound.sessionId, userId: bound.accountId, state: mint.state};
358
+ });
359
+ }
360
+
265
361
  /**
266
362
  * Re-mint the persisted session and return the fresh access token, or `null`
267
363
  * when no arm could produce one.
268
364
  *
269
365
  * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
270
- * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
271
- * or the device has no live session: drop the secret so the mint lane stops (or
272
- * clear the store on web, where there is no fallback), then fall to arm 2 on
273
- * native. A transient error — or a durable-persist failure — leaves the store and
366
+ * from the persisted `deviceId` + `deviceSecret`. An authenticated native
367
+ * holder's invalid-secret or `no_active_session` verdict suppresses key recovery
368
+ * while permitting a separately proved new shared holder. The latter verdict
369
+ * retains that holder and ends ACCOUNT recovery without a new key challenge.
370
+ * Classification still uses the existing 401 message markers, not a new parser.
371
+ * A transient error — or a durable-persist failure — leaves the store and
274
372
  * returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
275
373
  *
276
374
  * Arm 2 (native Commons identity): when the secret is absent or was just
@@ -287,9 +385,10 @@ export async function refreshDeviceSecretArm(deps: {
287
385
  */
288
386
  export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
289
387
  const { oxy, store } = deps;
388
+ if (deps.isCurrent && !deps.isCurrent()) return null;
290
389
  const identity = deps.identity ?? null;
291
390
  // The shared identity is never an identity-bound client's recovery path.
292
- const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative());
391
+ const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative()) && !(await store.isAutomaticIdentitySignInSuppressed?.());
293
392
  const epoch = oxy.http.getSessionEpoch();
294
393
  // Resolved per call: a re-established identity session can move the pin, and a
295
394
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
@@ -299,7 +398,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
299
398
  return recoverIdentitySession(oxy, store, identity);
300
399
  }
301
400
 
302
- const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
401
+ const arm1 = await refreshDeviceSecretArm({ oxy, store, pin, isCurrent: deps.isCurrent });
303
402
  switch (arm1.status) {
304
403
  case 'ok':
305
404
  return arm1.token;
@@ -324,6 +423,24 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
324
423
  return null;
325
424
  case 'invalid-secret':
326
425
  case 'no-session': {
426
+ if (!identity && deps.sharedDeviceCredential) {
427
+ const joined = await refreshSharedDeviceArm({oxy, store, shared: deps.sharedDeviceCredential, rejectedLocalHolder: true, isCurrent: deps.isCurrent});
428
+ if (joined.status === 'ok') return joined.token;
429
+ if (joined.status === 'session-ended' || joined.status === 'persist-failed') return null;
430
+ }
431
+ if (!identity && arm1.status === 'invalid-secret' && await store.isAutomaticIdentitySignInSuppressed?.()) return null;
432
+ if (arm1.status === 'no-session' && !identity) {
433
+ if (store.setAutomaticIdentitySignInSuppressed) return null;
434
+ // The holder is still valid, but its account session ended. Retain only
435
+ // the holder for a later deliberate/shared sign-in; never use the key
436
+ // to turn this signed-out verdict into a new account session.
437
+ const persisted = await store.load();
438
+ if (persisted?.deviceId && persisted.deviceSecret) {
439
+ await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
440
+ }
441
+ return null;
442
+ }
443
+ // Identity mode retains its separate pinned-key recovery contract.
327
444
  // 401: secret diverged or no live session. When a key-based arm 2 can still
328
445
  // recover (the native Commons identity, or an identity-bound client's own
329
446
  // primary key) drop ONLY the secret and keep the deviceId; otherwise (web) the
@@ -333,12 +450,6 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
333
450
  if (persisted) {
334
451
  await store.save({ ...persisted, deviceSecret: undefined });
335
452
  }
336
- } else if (arm1.status === 'no-session' && persisted?.deviceId && persisted.deviceSecret) {
337
- // Web, a credential the server still recognises: this origin stays a
338
- // holder of the browser's device (ADR 0029 D2) — the next sign-in in any
339
- // app lands on it, and this app follows without opening the bridge
340
- // again. Only the session fields go.
341
- await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
342
453
  } else {
343
454
  await store.clear();
344
455
  }
@@ -353,6 +464,11 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
353
464
  );
354
465
  break;
355
466
  case 'no-secret':
467
+ if (!identity && deps.sharedDeviceCredential) {
468
+ const joined = await refreshSharedDeviceArm({oxy, store, shared: deps.sharedDeviceCredential, isCurrent: deps.isCurrent});
469
+ if (joined.status === 'ok') return joined.token;
470
+ if (joined.status === 'session-ended' || joined.status === 'persist-failed' || joined.status === 'transient') return null;
471
+ }
356
472
  break;
357
473
  }
358
474
 
@@ -362,14 +478,14 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
362
478
 
363
479
  // Never after a sign-out: the shared identity is a KEY, not a session, and
364
480
  // proving it here would sign the user straight back in.
365
- if (allowCommonsIdentityFallback && !oxy.http.hasSessionEnded()) {
481
+ if (allowCommonsIdentityFallback && (deps.isCurrent?.() ?? true) && !oxy.http.hasSessionEnded() && !(await store.isAutomaticIdentitySignInSuppressed?.())) {
366
482
  try {
367
483
  // Planted here, not by the sign-in: a sign-out that lands while the
368
484
  // challenge round-trips must win, or the identity proof signs the user
369
485
  // straight back in.
370
486
  const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false });
371
487
  if (session?.accessToken) {
372
- if (oxy.http.getSessionEpoch() !== epoch) {
488
+ if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
373
489
  return null;
374
490
  }
375
491
  // Repopulate the fast device-secret lane from the identity re-mint.
@@ -383,7 +499,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
383
499
  expiresAt: session.expiresAt,
384
500
  });
385
501
  }
386
- if (oxy.http.getSessionEpoch() !== epoch) {
502
+ if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
387
503
  return null;
388
504
  }
389
505
  oxy.session.setAccessToken(session.accessToken);
@@ -44,7 +44,7 @@
44
44
  */
45
45
 
46
46
  import { logger } from '../logger';
47
- import type { AuthStateStore, PersistedAuthState } from './authStateStore';
47
+ import type { AuthStateStore, AuthStateWriteGuard, PersistedAuthState } from './authStateStore';
48
48
 
49
49
  /**
50
50
  * The zero-cookie device credential, as shared between apps. Exactly the pair
@@ -294,7 +294,9 @@ export async function publishProvenDeviceCredential(deps: {
294
294
  * seam, no lane left out, and no new call site to forget.
295
295
  * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
296
296
  * a storage primitive, and would run on every `load()`. Adoption is an
297
- * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
297
+ * explicit cold-boot/resume recovery instead; an existing local holder must
298
+ * first be rejected by the server, and a new shared holder must mint before
299
+ * conditional persistence.
298
300
  *
299
301
  * `clear()` deliberately does NOT clear the shared slot. This app signing out is
300
302
  * not authority over the device-wide join point: other apps may still be signed
@@ -313,8 +315,18 @@ export function createSharedMirroringAuthStateStore(deps: {
313
315
  let mirrored: SharedDeviceCredential | null = null;
314
316
 
315
317
  return {
318
+ ...local,
316
319
  load: () => local.load(),
317
320
  clear: () => local.clear(),
321
+ ...(local.saveIfCurrent ? {saveIfCurrent: async (state: PersistedAuthState, guard: AuthStateWriteGuard) => {
322
+ const committed = await local.saveIfCurrent?.(state, guard);
323
+ if (!committed) return false;
324
+ const credential = readLocalDeviceCredential(state);
325
+ if (credential && guard.isCurrent()) {
326
+ try { await publishProvenDeviceCredential({shared, credential}); } catch { /* Local durability already verified. */ }
327
+ }
328
+ return true;
329
+ }} : {}),
318
330
  save: async (state) => {
319
331
  // The durable local write is the contract this store owes its caller —
320
332
  // run it first and report ITS result, unchanged. The mirror is additive.
@@ -1,3 +1,5 @@
1
+ import { isLoopbackOrigin } from '@oxy.so/contracts';
2
+ export { isLoopbackOrigin } from '@oxy.so/contracts';
1
3
  /**
2
4
  * Official first-party web origin allowlist — shared by OAuth redirect
3
5
  * validation and the server-side trusted-origin checks.
@@ -24,19 +26,6 @@ const OFFICIAL_APEXES = new Set([
24
26
  * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
25
27
  * or `[::1]` on any port, http or https).
26
28
  */
27
- export function isLoopbackOrigin(origin: string): boolean {
28
- try {
29
- const parsed = new URL(origin);
30
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
31
- return false;
32
- }
33
- const host = parsed.hostname.toLowerCase();
34
- return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
29
  /** Whether an origin belongs to the official Oxy web ecosystem. */
41
30
  export function isOfficialWebOrigin(origin: string): boolean {
42
31
  if (isLoopbackOrigin(origin)) {