@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.
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 +23 -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 +21 -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 +9 -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 +19 -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
@@ -1,3 +1,23 @@
1
+ /**
2
+ * `oxy.accounts` — the unified account graph (`/accounts`).
3
+ *
4
+ * An account is a relational, tree-structured principal (the `User` document
5
+ * generalised): a `personal` account is a human login at the root of its tree;
6
+ * `organization` / `project` / `bot` / `channel` accounts are non-login
7
+ * principals operated through membership. Accounts form a tree
8
+ * (`parentAccountId`) and expose a single membership model (`AccountMember`)
9
+ * with a unified role set and an explicit-but-inheritable cascade down the
10
+ * subtree. The applications an account owns are `oxy.apps`.
11
+ *
12
+ * Reference accounts by their `_id` (`accountId`, the underlying `User._id`)
13
+ * and members by their member `_id` — never by name, slug, or handle.
14
+ *
15
+ * SWITCHING INTO AN ACCOUNT: `accounts.actAs(accountId)` mints a REAL
16
+ * session for the target account and plants it as the active session — there is
17
+ * no per-request "acting-as" header. Identity is carried by the session/token,
18
+ * so a switch propagates through reload and cross-domain exactly like a login.
19
+ */
20
+ import { agentKeyOperationSchema, executeAgentKeyOperationSchema, agentKeyOperationChallengeSchema, agentKeyOperationResultSchema, agentKeyListResponseSchema } from '@oxy.so/contracts';
1
21
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
2
22
  import { evictOxyIdentityCache } from '../utils/identityCacheSweep.js';
3
23
  import { evictOxyAccountForestCache, oxyAccountDetailCacheKey, OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX, } from '../utils/accountCacheSweep.js';
@@ -67,10 +87,28 @@ export class AccountMembersApi {
67
87
  return result;
68
88
  }
69
89
  }
90
+ /** Current account governors and autonomous rotation use action-bound proofs. */
91
+ export class AccountAgentKeysApi {
92
+ constructor(ctx) {
93
+ this.ctx = ctx;
94
+ }
95
+ async list(accountId) {
96
+ return agentKeyListResponseSchema.parse(await this.ctx.request('GET', `/accounts/${enc(accountId)}/agent-keys`, undefined, { cache: false }));
97
+ }
98
+ async requestChallenge(accountId, operation) {
99
+ const response = await this.ctx.request('POST', `/accounts/${enc(accountId)}/agent-keys/challenge`, agentKeyOperationSchema.parse(operation), { cache: false });
100
+ return agentKeyOperationChallengeSchema.parse(response);
101
+ }
102
+ async execute(accountId, operation, proof) {
103
+ const response = await this.ctx.request('POST', `/accounts/${enc(accountId)}/agent-keys/execute`, executeAgentKeyOperationSchema.parse({ operation, proof }), { cache: false });
104
+ return agentKeyOperationResultSchema.parse(response);
105
+ }
106
+ }
70
107
  export class AccountsApi {
71
108
  constructor(ctx) {
72
109
  this.ctx = ctx;
73
110
  this.members = new AccountMembersApi(ctx);
111
+ this.agentKeys = new AccountAgentKeysApi(ctx);
74
112
  }
75
113
  /**
76
114
  * List the accounts the caller can access: their own personal (root)
@@ -167,10 +167,10 @@ export class AppsApi {
167
167
  * consent, authorize, and device-flow approval screens before any session
168
168
  * exists.
169
169
  */
170
- async getPublic(clientId) {
170
+ async getPublic(clientId, options = {}) {
171
171
  const res = await this.ctx.request('GET', `/auth/oauth/client/${enc(clientId)}`, undefined,
172
172
  // Public client metadata (pre-session consent UI) — skip the bearer preflight.
173
- { cache: true, cacheTTL: MEDIUM_TTL, skipAuth: true });
173
+ { cache: options.cache ?? true, cacheTTL: MEDIUM_TTL, skipAuth: true });
174
174
  return res.application;
175
175
  }
176
176
  /**
@@ -1,4 +1,4 @@
1
- import { emailSignInLinkResponseSchema, emailSignInPendingSchema, emailSignInStartResponseSchema, emailVerificationConfirmResponseSchema, emailVerificationStartResponseSchema, loginResultSchema, safeParseContract, secondFactorRequiredSchema, signInMethodsSchema, totpBackupCodesResponseSchema, totpEnrollResponseSchema, } from '@oxy.so/contracts';
1
+ import { agentProofClaimsSchema, agentChallengeRequestSchema, agentVerifyRequestSchema, emailSignInLinkResponseSchema, emailSignInPendingSchema, emailSignInStartResponseSchema, emailVerificationConfirmResponseSchema, emailVerificationStartResponseSchema, loginResultSchema, safeParseContract, secondFactorRequiredSchema, signInMethodsSchema, totpBackupCodesResponseSchema, totpEnrollResponseSchema, } from '@oxy.so/contracts';
2
2
  import { OxyAuthenticationError } from '../OxyServices.errors.js';
3
3
  import { logger } from '../logger/index.js';
4
4
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
@@ -264,7 +264,8 @@ export class AuthOAuthApi {
264
264
  * out, so the app can run the OAuth flow again. It never degrades into a
265
265
  * session that looks alive and cannot refresh.
266
266
  */
267
- async exchangeCode(params) {
267
+ /** `plantTokens: false` defers bearer installation to a validated provider commit. */
268
+ async exchangeCode(params, options = {}) {
268
269
  const form = new URLSearchParams({
269
270
  grant_type: 'authorization_code',
270
271
  code: params.code,
@@ -295,7 +296,9 @@ export class AuthOAuthApi {
295
296
  }
296
297
  const expiresInSec = typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
297
298
  const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
298
- if (accessToken) {
299
+ // Provider completion validates the expected subject before committing.
300
+ // Direct callers retain the historical token-planting default.
301
+ if (accessToken && options.plantTokens !== false) {
299
302
  this.ctx.oxy.session.setAccessToken(accessToken);
300
303
  }
301
304
  if (!deviceId || !deviceSecret) {
@@ -642,6 +645,22 @@ export class AuthCommonsApi {
642
645
  { cache: false, skipAuth: true });
643
646
  }
644
647
  }
648
+ /** Bot entry uses caller-owned keys and the normal account session lane. */
649
+ export class AuthAgentApi {
650
+ constructor(ctx) {
651
+ this.ctx = ctx;
652
+ }
653
+ async requestChallenge(publicKey) {
654
+ const body = agentChallengeRequestSchema.parse({ publicKey });
655
+ const response = await this.ctx.request('POST', '/auth/agent/challenge', body, { cache: false, skipAuth: true });
656
+ return agentProofClaimsSchema.parse(response);
657
+ }
658
+ async verify(publicKey, proof) {
659
+ const body = agentVerifyRequestSchema.parse({ publicKey, ...proof });
660
+ const response = await this.ctx.request('POST', '/auth/agent/verify', body, { cache: false, skipAuth: true });
661
+ return plantSession(this.ctx, response, 'auth/agent/verify');
662
+ }
663
+ }
645
664
  export class AuthApi {
646
665
  constructor(ctx) {
647
666
  this.ctx = ctx;
@@ -650,6 +669,7 @@ export class AuthApi {
650
669
  this.totp = new AuthTotpApi(ctx);
651
670
  this.oauth = new AuthOAuthApi(ctx);
652
671
  this.commons = new AuthCommonsApi(ctx);
672
+ this.agent = new AuthAgentApi(ctx);
653
673
  }
654
674
  /**
655
675
  * Register a new identity with public key authentication
@@ -1,9 +1,44 @@
1
+ /**
2
+ * `oxy.billing` — the signed-in user's subscription, wallet and payment history.
3
+ *
4
+ * Every call reads the SIGNED-IN user's data unless a `userId` is passed (the
5
+ * API refuses any other user's). Checkout, plan changes and cancellation live
6
+ * under `/billing` (Stripe) and are not wrapped here yet.
7
+ *
8
+ * The types model the JSON these routes actually return (`subscription.controller`,
9
+ * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
10
+ * arrive as ISO-8601 strings.
11
+ */
12
+ import { subjectProductAccessQuerySchema, subjectProductAccessSchema, } from '@oxy.so/contracts';
13
+ import { productSubscriptionCancellationResultSchema, cancelProductSubscriptionSchema, productSubscriptionsResponseSchema, subscriptionCreditGrantsResponseSchema, } from '@oxy.so/contracts';
1
14
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
2
15
  const WALLET_TTL = 60 * 1000;
3
16
  export class BillingApi {
4
17
  constructor(ctx) {
5
18
  this.ctx = ctx;
6
19
  }
20
+ /** Product rights only; requires a production application-bound user session. Never cached. */
21
+ async productAccess(query) {
22
+ const parsed = subjectProductAccessQuerySchema.parse(query);
23
+ const response = await this.ctx.request('GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}`, undefined, { cache: false });
24
+ return subjectProductAccessSchema.parse(response);
25
+ }
26
+ /** Named commercial sources for the current subject; no inferred plan collapse. */
27
+ async productSubscriptions() {
28
+ return productSubscriptionsResponseSchema.parse(await this.ctx.request("GET", "/billing/product-subscriptions", undefined, { cache: false })).subscriptions;
29
+ }
30
+ /** Per-grant credit provenance; no interpretation of the historical mixed balance. */
31
+ async creditGrants() {
32
+ return subscriptionCreditGrantsResponseSchema.parse(await this.ctx.request("GET", "/billing/credit-grants", undefined, { cache: false })).grants;
33
+ }
34
+ /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
35
+ async cancelProductSubscription(sourceId, expectedSubjectAccountId) {
36
+ await this.ctx.request("POST", "/billing/product-subscriptions/cancel", cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false });
37
+ }
38
+ /** Provider acceptance versus completed local cancellation, for user-facing flows. */
39
+ async cancelProductSubscriptionWithStatus(sourceId, expectedSubjectAccountId) {
40
+ return productSubscriptionCancellationResultSchema.parse(await this.ctx.request('POST', '/billing/product-subscriptions/cancel', cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }), { cache: false }));
41
+ }
7
42
  /** The signed-in user's payment history. Never cached. */
8
43
  async payments() {
9
44
  return this.ctx.request('GET', '/payments/user', undefined, { cache: false });
@@ -44,9 +44,8 @@ import { runColdBoot } from '../utils/coldBoot.js';
44
44
  import { isNative as detectNative } from '../utils/platform.js';
45
45
  import { logger } from '../logger/index.js';
46
46
  import { computeIdentityTag } from '../utils/cacheKey.js';
47
- import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh.js';
47
+ import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm, refreshSharedDeviceArm } from '../session/refresh.js';
48
48
  import { establishIdentitySession, resolveIdentityPin, } from '../session/identitySession.js';
49
- import { decideSharedDeviceJoin, } from '../session/sharedDeviceCredential.js';
50
49
  /**
51
50
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
52
51
  * a side effect, invokes `onSession` (winning session, token already planted) or
@@ -70,6 +69,8 @@ export async function runSessionColdBoot(opts) {
70
69
  // Boot-local (not module-level) so it cannot leak across boots or break under
71
70
  // bundler re-evaluation.
72
71
  let signedOutReason = 'no_session';
72
+ let rejectedLocalHolder = false;
73
+ let accountRecoverySuperseded = false;
73
74
  // Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
74
75
  // (local, but storage-backed) read is covered by `overallDeadlineMs`, and
75
76
  // resolved at most once per boot so two steps cannot disagree. Always `null`
@@ -101,6 +102,8 @@ export async function runSessionColdBoot(opts) {
101
102
  steps.push({
102
103
  id: 'warm-token-plant',
103
104
  run: async () => {
105
+ if (identityBinding === null && isNative && await store.isAutomaticIdentitySignInSuppressed?.())
106
+ return { kind: 'skip' };
104
107
  const persisted = await store.load();
105
108
  if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
106
109
  return { kind: 'skip' };
@@ -173,6 +176,11 @@ export async function runSessionColdBoot(opts) {
173
176
  },
174
177
  };
175
178
  case 'invalid-secret': {
179
+ if (identityBinding === null && store.setAutomaticIdentitySignInSuppressed && await store.isAutomaticIdentitySignInSuppressed?.()) {
180
+ rejectedLocalHolder = true;
181
+ signedOutReason = 'no_session';
182
+ return { kind: 'skip' };
183
+ }
176
184
  // Stale/diverged secret — drop it so the mint lane stops firing. On
177
185
  // native the Commons-proof step below can still recover; on web this ends
178
186
  // signed out. Setting it undefined drops the key on the store's JSON
@@ -184,6 +192,7 @@ export async function runSessionColdBoot(opts) {
184
192
  return { kind: 'skip' };
185
193
  }
186
194
  case 'no-session':
195
+ rejectedLocalHolder = identityBinding === null;
187
196
  // Device known, no live session — authoritative signed-out. Keep the
188
197
  // secret (the device may sign in again).
189
198
  signedOutReason = 'no_session';
@@ -205,7 +214,10 @@ export async function runSessionColdBoot(opts) {
205
214
  logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
206
215
  return { kind: 'skip' };
207
216
  case 'session-ended':
208
- // Signed out while the boot was minting; the arm planted nothing.
217
+ // The prior holder/epoch was superseded. This boot must not turn a
218
+ // late rejection into a fresh Commons sign-in; a new boot can reconcile.
219
+ if (identityBinding === null)
220
+ accountRecoverySuperseded = true;
209
221
  return { kind: 'skip' };
210
222
  case 'no-secret':
211
223
  return { kind: 'skip' };
@@ -270,63 +282,16 @@ export async function runSessionColdBoot(opts) {
270
282
  // The adoption itself is local, but it is only worth committing alongside
271
283
  // a mint that proves the credential — so the whole lane is online-gated
272
284
  // like every other network step.
273
- enabled: () => isNative && !isOffline(),
285
+ enabled: () => isNative && !isOffline() && !accountRecoverySuperseded,
274
286
  run: async () => {
275
- const before = await store.load();
276
- const decision = decideSharedDeviceJoin(before, await sharedSlot.read());
277
- if (decision.action === 'skip') {
278
- logger.debug(`shared device credential not adopted (${decision.reason})`, { component: 'sessionColdBoot', method: 'shared-device-adopt' });
279
- return { kind: 'skip' };
280
- }
281
- // Restore the store to exactly what it held before this lane touched it.
282
- // A credential we adopted and could not prove must not be left behind for
283
- // the next boot's mint lane to keep retrying.
284
- const revert = async () => {
285
- if (before) {
286
- await store.save(before);
287
- }
288
- else {
289
- await store.clear();
290
- }
291
- };
292
- const adopted = {
293
- // The mint fills both in from the device's live state; carrying the
294
- // previous session's ids into a different device session would be a
295
- // lie for however long the mint takes.
296
- sessionId: '',
297
- userId: '',
298
- deviceId: decision.credential.deviceId,
299
- deviceSecret: decision.credential.deviceSecret,
300
- };
301
- if (!(await store.save(adopted))) {
302
- logger.error('adopted the shared device credential but it could not be durably persisted — reverting', undefined, { component: 'sessionColdBoot', method: 'shared-device-adopt' });
303
- await revert();
304
- return { kind: 'skip' };
305
- }
306
- const result = await refreshDeviceSecretArm({ oxy, store, pin: null });
287
+ const result = await refreshSharedDeviceArm({ oxy, store, shared: sharedSlot, rejectedLocalHolder });
307
288
  if (result.status === 'ok') {
308
- return {
309
- kind: 'session',
310
- session: {
311
- sessionId: result.sessionId,
312
- userId: result.userId,
313
- accessToken: result.token,
314
- },
315
- };
316
- }
317
- if (result.status === 'invalid-secret') {
318
- // The one place we hold POSITIVE proof that the exact bytes in the
319
- // shared slot are dead — the server rejected them by name. Clearing it
320
- // signs nobody out (a credential the server does not recognise cannot
321
- // be minting for anyone) and it is what stops a dead credential from
322
- // blocking every future install: a stale slot owned by a different
323
- // `deviceId` is otherwise never overwritten, by design.
324
- await sharedSlot.clear();
289
+ return { kind: 'session', session: {
290
+ sessionId: result.sessionId, userId: result.userId, accessToken: result.token, state: result.state,
291
+ } };
325
292
  }
326
- else if (result.status === 'no-session') {
293
+ if (result.status === 'no-session')
327
294
  signedOutReason = 'no_session';
328
- }
329
- await revert();
330
295
  return { kind: 'skip' };
331
296
  },
332
297
  });
@@ -354,8 +319,11 @@ export async function runSessionColdBoot(opts) {
354
319
  id: 'commons-proof-signin',
355
320
  enabled: () => isNative && !isOffline(),
356
321
  run: async () => {
357
- const session = await oxy.auth.signInWithCommonsIdentity({ requestOptions: { retry: false } });
358
- if (!session?.accessToken) {
322
+ if (accountRecoverySuperseded || await store.isAutomaticIdentitySignInSuppressed?.())
323
+ return { kind: 'skip' };
324
+ const epoch = oxy.http.getSessionEpoch();
325
+ const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false, requestOptions: { retry: false } });
326
+ if (!session?.accessToken || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
359
327
  return { kind: 'skip' };
360
328
  }
361
329
  // `verifyChallenge` issues a deviceSecret; persist it so the next
@@ -371,6 +339,9 @@ export async function runSessionColdBoot(opts) {
371
339
  expiresAt: session.expiresAt,
372
340
  });
373
341
  }
342
+ if (oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.())
343
+ return { kind: 'skip' };
344
+ oxy.session.setAccessToken(session.accessToken);
374
345
  return {
375
346
  kind: 'session',
376
347
  session: {
@@ -1,3 +1,15 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _OxyInferenceClient_instances, _OxyInferenceClient_baseURL, _OxyInferenceClient_credential, _OxyInferenceClient_fetch, _OxyInferenceClient_bearer, _OxyInferenceClient_headers, _OxyInferenceClient_request;
1
13
  /**
2
14
  * The Oxy inference client — one surface, two credential lanes (issue #972,
3
15
  * workstream 15).
@@ -49,18 +61,6 @@
49
61
  * schema, so a rename on either side fails a build rather than a customer's
50
62
  * request.
51
63
  */
52
- var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
53
- if (kind === "m") throw new TypeError("Private method is not writable");
54
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
55
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
56
- return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
57
- };
58
- var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
59
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
60
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
61
- return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
62
- };
63
- var _OxyInferenceClient_instances, _OxyInferenceClient_baseURL, _OxyInferenceClient_credential, _OxyInferenceClient_fetch, _OxyInferenceClient_bearer, _OxyInferenceClient_headers, _OxyInferenceClient_request;
64
64
  import { decisionRequestSchema, decisionSuccessSchema, decisionAnswersMatch } from '@oxy.so/contracts';
65
65
  import { INFERENCE_ERROR_CODES, MAX_INFERENCE_AUDIO_BYTES, inferenceAudioMediaTypeSchema, inferenceStreamEventSchema, modelIdSchema, } from '@oxy.so/contracts';
66
66
  /** The base URL of the Oxy API, when a caller names none. */
@@ -379,7 +379,15 @@ export class OxyInferenceClient {
379
379
  * somebody else.
380
380
  */
381
381
  async getGeneration(id, options = {}) {
382
- const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', `/v1/generations/${encodeURIComponent(id)}`, { ...(options.signal === undefined ? {} : { signal: options.signal }) });
382
+ const record = await this.getGenerationRecord(id, options);
383
+ if (record.schemaVersion !== 1) {
384
+ throw new OxyInferenceProtocolError('Internal usage has no financial receipt. Use getGenerationRecord().', record.requestId);
385
+ }
386
+ return record;
387
+ }
388
+ /** Read a financial receipt (v1) or an internal technical record (v2). */
389
+ async getGenerationRecord(id, options = {}) {
390
+ const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', `/v1/generations/${encodeURIComponent(id)}`, options);
383
391
  return body.data;
384
392
  }
385
393
  }
@@ -26,6 +26,18 @@
26
26
  * Node only.
27
27
  */
28
28
  import { jwtDecode } from 'jwt-decode';
29
+ import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
30
+ /** Unverified cache metadata only: an old lifetime forces a new signed mint. */
31
+ function reusableServiceToken(token) {
32
+ try {
33
+ const claims = jwtDecode(token);
34
+ return hasBoundedServiceTokenLifetime(claims) && typeof claims.exp === 'number'
35
+ && claims.exp > Math.floor(Date.now() / 1000);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
29
41
  import { loadNodeCrypto } from '@oxy.so/protocol';
30
42
  import { OxyServices } from '../OxyServices.js';
31
43
  import { logger } from '../logger/index.js';
@@ -53,10 +65,10 @@ const WORKLOAD_CACHE_KEY = 'workload-identity';
53
65
  const TOKEN_REUSE_MARGIN_MS = 60000;
54
66
  /** Bound on remembered `appId:userId` delegation answers. */
55
67
  const ACTING_AS_CACHE_MAX = 1000;
56
- /** A positive grant is reused for 5 minutes — the revocation latency window. */
57
- const ACTING_AS_GRANT_TTL_MS = 5 * 60 * 1000;
58
- /** A negative answer (or a failed lookup) is reused for 1 minute. */
59
- const ACTING_AS_DENIAL_TTL_MS = 60 * 1000;
68
+ /** Explicit read reuse only; effects always perform a fresh lookup. */
69
+ const ACTING_AS_GRANT_TTL_MS = 60000;
70
+ /** Explicit authoritative read denials last ten seconds; failures are never cached. */
71
+ const ACTING_AS_DENIAL_TTL_MS = 10000;
60
72
  export class OxyServer extends OxyServices {
61
73
  constructor(config) {
62
74
  super(config);
@@ -65,6 +77,7 @@ export class OxyServer extends OxyServices {
65
77
  this.serviceApiSecret = null;
66
78
  this.actingAs = new Map();
67
79
  this.actingAsPending = new Map();
80
+ this.actingAsGenerations = new Map();
68
81
  /** Public keys only; never private material. */
69
82
  this.jwksCache = { keys: new Map(), expiresAt: 0, lastAttemptAt: 0 };
70
83
  /**
@@ -118,7 +131,7 @@ export class OxyServer extends OxyServices {
118
131
  },
119
132
  jwksCache: this.jwksCache,
120
133
  validateSession: (sessionId, options) => this.session.validate(sessionId, options),
121
- verifyActingAs: (appId, userId) => this.verifyActingAs(appId, userId),
134
+ verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
122
135
  });
123
136
  }
124
137
  /** Files, plus the service-only metadata and linked-URL lookups. */
@@ -204,7 +217,7 @@ export class OxyServer extends OxyServices {
204
217
  });
205
218
  throw new ServiceCredentialMismatchError();
206
219
  }
207
- if (entry.token && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
220
+ if (entry.token && reusableServiceToken(entry.token) && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
208
221
  return entry.token;
209
222
  }
210
223
  if (entry.pending) {
@@ -266,32 +279,49 @@ export class OxyServer extends OxyServices {
266
279
  }
267
280
  // ── Delegation and account events ────────────────────────────────────────
268
281
  /**
269
- * Whether service app `appId` holds an active delegation grant to act for
270
- * `userId`; the grant's scopes, or `null`. Used by `middleware.auth()` for
271
- * `X-Oxy-User-Id`.
272
- *
273
- * Answers are cached per `appId:userId` (a grant for 5 minutes — the
274
- * revocation window; a denial or failed lookup for 1 minute), bounded to the
275
- * 1000 most recent pairs, and concurrent lookups of one pair share a single
276
- * request.
282
+ * Resolve authority live by default. Explicit read-only reuse lasts at most
283
+ * 60s positive / 10s negative, anchored to request start; errors never cache.
284
+ * Effects do not share an older in-flight read. Durable epoch plus local
285
+ * completion ordering prevents ABA and late positive replies overwriting a
286
+ * newer refusal even when app/credential revocation leaves epoch unchanged.
277
287
  */
278
- async verifyActingAs(appId, userId) {
279
- const cacheKey = `${appId}:${userId}`;
280
- const cached = this.actingAs.get(cacheKey);
281
- if (cached && cached.expiresAt > Date.now()) {
282
- // Refresh recency for the LRU bound.
283
- this.actingAs.delete(cacheKey);
284
- this.actingAs.set(cacheKey, cached);
285
- return cached.result;
286
- }
287
- const inflight = this.actingAsPending.get(cacheKey);
288
- if (inflight)
289
- return inflight;
290
- const lookup = this.lookupActingAs(appId, userId).finally(() => {
291
- this.actingAsPending.delete(cacheKey);
292
- });
288
+ async verifyActingAs(appId, userId, options = {}) {
289
+ const pair = JSON.stringify([appId, userId]);
290
+ const cacheKey = JSON.stringify([appId, userId, options.credentialId, options.ownerAccountId, options.environment]);
291
+ if (options.cache === true) {
292
+ const cached = this.actingAs.get(cacheKey);
293
+ if (cached && cached.expiresAt > Date.now()) {
294
+ this.actingAs.delete(cacheKey);
295
+ this.actingAs.set(cacheKey, cached);
296
+ return cached.result;
297
+ }
298
+ const pending = this.actingAsPending.get(cacheKey);
299
+ if (pending)
300
+ return pending;
301
+ }
302
+ let generation = this.actingAsGenerations.get(pair);
303
+ if (!generation) {
304
+ generation = { epoch: 0n, issued: 0, completed: 0 };
305
+ this.actingAsGenerations.set(pair, generation);
306
+ if (this.actingAsGenerations.size > ACTING_AS_CACHE_MAX) {
307
+ const oldest = this.actingAsGenerations.keys().next().value;
308
+ if (oldest !== undefined)
309
+ this.actingAsGenerations.delete(oldest);
310
+ }
311
+ }
312
+ const serial = ++generation.issued;
313
+ const startedAt = Date.now();
314
+ const lookup = this.lookupActingAs(appId, userId, options, pair, cacheKey, generation, serial, startedAt);
315
+ if (options.cache !== true)
316
+ return lookup;
293
317
  this.actingAsPending.set(cacheKey, lookup);
294
- return lookup;
318
+ try {
319
+ return await lookup;
320
+ }
321
+ finally {
322
+ if (this.actingAsPending.get(cacheKey) === lookup)
323
+ this.actingAsPending.delete(cacheKey);
324
+ }
295
325
  }
296
326
  // ── Internals ────────────────────────────────────────────────────────────
297
327
  /**
@@ -347,7 +377,7 @@ export class OxyServer extends OxyServices {
347
377
  /** A token obtained by attestation, cached like a credential's (one identity per process). */
348
378
  async workloadServiceToken() {
349
379
  const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
350
- if (entry?.token && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS)
380
+ if (entry?.token && reusableServiceToken(entry.token) && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS)
351
381
  return entry.token;
352
382
  if (entry?.pending)
353
383
  return entry.pending;
@@ -378,36 +408,62 @@ export class OxyServer extends OxyServices {
378
408
  settled.pending = null;
379
409
  }
380
410
  }
381
- async lookupActingAs(appId, userId) {
411
+ async lookupActingAs(appId, userId, options, pair, cacheKey, generation, serial, startedAt) {
382
412
  try {
383
- // The verify endpoint admits only a platform-trusted caller, so this
384
- // carries the VERIFIER's own service token. No retry and a short timeout:
385
- // it runs inside request-handling middleware, and a retry loop would
386
- // multiply the latency of every delegated request. A verifier with no
387
- // credential throws here and is refused below — a host that cannot prove
388
- // who it is has no business learning who delegated to whom.
389
413
  const serviceToken = await this.serviceToken();
390
- const result = await this.request('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}` } });
391
- const verified = result?.authorized
392
- ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
393
- : null;
394
- this.rememberActingAs(`${appId}:${userId}`, verified, ACTING_AS_GRANT_TTL_MS);
414
+ const result = await this.request('GET', '/internal/service-acting-as/verify', { appId, userId, ...(options.credentialId ? { credentialId: options.credentialId,
415
+ ownerAccountId: options.ownerAccountId, environment: options.environment } : {}) }, { cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}` } });
416
+ // A deadline bounds how long an already old response can authorize. It
417
+ // is not by itself a measurement of end-to-end revocation latency.
418
+ if (this.actingAsGenerations.get(pair) !== generation || serial < generation.completed)
419
+ return null;
420
+ generation.completed = serial;
421
+ this.clearActingAsPair(appId, userId);
422
+ if (Date.now() - startedAt >= 5000)
423
+ return null;
424
+ const rawEpoch = result?.epoch;
425
+ if (typeof rawEpoch !== 'string' || !/^(?:0|[1-9][0-9]{0,18})$/.test(rawEpoch))
426
+ return null;
427
+ const epoch = BigInt(rawEpoch);
428
+ if (epoch > 9223372036854775807n)
429
+ return null;
430
+ if (typeof result.authorized !== 'boolean' || !Array.isArray(result.scopes)
431
+ || !result.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope.trim() === scope)
432
+ || new Set(result.scopes).size !== result.scopes.length
433
+ || (!result.authorized && result.scopes.length !== 0))
434
+ return null;
435
+ const older = epoch < generation.epoch;
436
+ if (epoch > generation.epoch)
437
+ generation.epoch = epoch;
438
+ if (result.authorized && older)
439
+ return null;
440
+ const verified = result.authorized === true
441
+ ? { authorized: true, scopes: result.scopes, epoch: rawEpoch } : null;
442
+ if (options.cache === true)
443
+ this.rememberActingAs(cacheKey, verified, startedAt + (verified ? ACTING_AS_GRANT_TTL_MS : ACTING_AS_DENIAL_TTL_MS));
395
444
  return verified;
396
445
  }
397
446
  catch (error) {
398
- logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
399
- component: 'auth',
400
- method: 'verifyActingAs',
401
- appId,
402
- userId,
447
+ if (this.actingAsGenerations.get(pair) === generation && serial >= generation.completed) {
448
+ generation.completed = serial;
449
+ this.clearActingAsPair(appId, userId);
450
+ }
451
+ logger.warn('[oxy.auth] verifyActingAs lookup failed — not cached', {
452
+ component: 'auth', method: 'verifyActingAs', appId, userId,
403
453
  }, error);
404
- this.rememberActingAs(`${appId}:${userId}`, null, ACTING_AS_DENIAL_TTL_MS);
405
454
  return null;
406
455
  }
407
456
  }
408
- rememberActingAs(cacheKey, result, ttlMs) {
457
+ clearActingAsPair(appId, userId) {
458
+ for (const key of this.actingAs.keys()) {
459
+ const [cachedApp, cachedUser] = JSON.parse(key);
460
+ if (cachedApp === appId && cachedUser === userId)
461
+ this.actingAs.delete(key);
462
+ }
463
+ }
464
+ rememberActingAs(cacheKey, result, expiresAt) {
409
465
  this.actingAs.delete(cacheKey);
410
- this.actingAs.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
466
+ this.actingAs.set(cacheKey, { result, expiresAt });
411
467
  if (this.actingAs.size > ACTING_AS_CACHE_MAX) {
412
468
  const oldest = this.actingAs.keys().next().value;
413
469
  if (oldest !== undefined)
@@ -0,0 +1,16 @@
1
+ /** Autonomous entry helper. The signer owns its private key (for example in a KMS). */
2
+ import { buildAgentProofMessage } from '@oxy.so/contracts';
3
+ import { normalizeSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
4
+ /** Explicit sign-in; no timer, automatic escalation or service credential lane. */
5
+ export async function signInAgentAccount(options) {
6
+ const { client, accountId, signer } = options;
7
+ const publicKey = normalizeSecp256k1PublicKey(signer.publicKey.trim());
8
+ const claims = await client.auth.agent.requestChallenge(publicKey);
9
+ const timestamp = Date.now();
10
+ if (claims.action !== 'agent_signin' || claims.accountId !== accountId || claims.actorId !== accountId
11
+ || normalizeSecp256k1PublicKey(claims.publicKey) !== publicKey || !claims.authMethodId || claims.expiresAt <= timestamp) {
12
+ throw new Error('Agent challenge does not match this account and signer');
13
+ }
14
+ const signature = await signer.signMessage(buildAgentProofMessage(claims, timestamp));
15
+ return client.auth.agent.verify(publicKey, { challenge: claims.challenge, timestamp, signature });
16
+ }