@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
@@ -9,6 +9,16 @@
9
9
  * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
10
10
  * arrive as ISO-8601 strings.
11
11
  */
12
+ import { type SubjectProductAccess,
13
+ type SubjectProductAccessQuery,
14
+ subjectProductAccessQuerySchema, subjectProductAccessSchema, } from '@oxy.so/contracts';
15
+ import {
16
+ type ProductSubscriptionSummary, type SubscriptionCreditGrant, type ProductSubscriptionCancellationResult,
17
+ productSubscriptionCancellationResultSchema,
18
+ cancelProductSubscriptionSchema,
19
+ productSubscriptionsResponseSchema,
20
+ subscriptionCreditGrantsResponseSchema,
21
+ } from '@oxy.so/contracts';
12
22
  import type { OxyContext } from '../client/context';
13
23
 
14
24
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
@@ -105,13 +115,65 @@ export interface WalletTransactionsPage {
105
115
  export class BillingApi {
106
116
  constructor(protected readonly ctx: OxyContext) {}
107
117
 
108
- /** The signed-in user's payment history. Never cached. */
109
- async payments(): Promise<Payment[]> {
118
+ /** Product rights only; requires a production application-bound user session. Never cached. */
119
+ async productAccess(query: SubjectProductAccessQuery): Promise<SubjectProductAccess> {
120
+ const parsed = subjectProductAccessQuerySchema.parse(query);
121
+ const response = await this.ctx.request<unknown>('GET',
122
+ `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}`,
123
+ undefined, { cache: false });
124
+ return subjectProductAccessSchema.parse(response);
125
+ }
126
+
127
+ /** Named commercial sources for the current subject; no inferred plan collapse. */
128
+ async productSubscriptions(): Promise<ProductSubscriptionSummary[]> {
129
+ return productSubscriptionsResponseSchema.parse(
130
+ await this.ctx.request<unknown>(
131
+ "GET",
132
+ "/billing/product-subscriptions",
133
+ undefined,
134
+ { cache: false },
135
+ ),
136
+ ).subscriptions;
137
+ }
138
+
139
+ /** Per-grant credit provenance; no interpretation of the historical mixed balance. */
140
+ async creditGrants(): Promise<SubscriptionCreditGrant[]> {
141
+ return subscriptionCreditGrantsResponseSchema.parse(
142
+ await this.ctx.request<unknown>(
143
+ "GET",
144
+ "/billing/credit-grants",
145
+ undefined,
146
+ { cache: false },
147
+ ),
148
+ ).grants;
149
+ }
150
+
151
+ /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
152
+ async cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string): Promise<void> {
153
+ await this.ctx.request(
154
+ "POST",
155
+ "/billing/product-subscriptions/cancel",
156
+ cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
157
+ { cache: false },
158
+ );
159
+ }
160
+
161
+ /** Provider acceptance versus completed local cancellation, for user-facing flows. */
162
+ async cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string): Promise<ProductSubscriptionCancellationResult> {
163
+ return productSubscriptionCancellationResultSchema.parse(await this.ctx.request<unknown>(
164
+ 'POST', '/billing/product-subscriptions/cancel',
165
+ cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
166
+ { cache: false },
167
+ ));
168
+ }
169
+
170
+ /** The signed-in user's payment history. Never cached. */
171
+ async payments(): Promise<Payment[]> {
110
172
  return this.ctx.request<Payment[]>('GET', '/payments/user', undefined, { cache: false });
111
173
  }
112
174
 
113
- /** A user's subscription (default: the signed-in user's). */
114
- async subscription(userId?: string): Promise<Subscription> {
175
+ /** A user's subscription (default: the signed-in user's). */
176
+ async subscription(userId?: string): Promise<Subscription> {
115
177
  const id = this.resolveUserId(userId);
116
178
  return this.ctx.request<Subscription>('GET', `/subscription/${id}`, undefined, {
117
179
  cache: true,
@@ -119,14 +181,14 @@ export class BillingApi {
119
181
  });
120
182
  }
121
183
 
122
- /** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
123
- async wallet(userId?: string): Promise<Wallet> {
184
+ /** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
185
+ async wallet(userId?: string): Promise<Wallet> {
124
186
  const id = this.resolveUserId(userId);
125
187
  return this.ctx.request<Wallet>('GET', `/wallet/${id}`, undefined, { cache: true, cacheTTL: WALLET_TTL });
126
188
  }
127
189
 
128
- /** A page of a user's wallet transactions (default: the signed-in user's). Never cached. */
129
- async walletTransactions(
190
+ /** A page of a user's wallet transactions (default: the signed-in user's). Never cached. */
191
+ async walletTransactions(
130
192
  options: { userId?: string; limit?: number; offset?: number } = {},
131
193
  ): Promise<WalletTransactionsPage> {
132
194
  const id = this.resolveUserId(options.userId);
@@ -136,7 +198,7 @@ export class BillingApi {
136
198
  return this.ctx.request<WalletTransactionsPage>('GET', `/wallet/transactions/${id}`, params, { cache: false });
137
199
  }
138
200
 
139
- private resolveUserId(userId?: string): string {
201
+ private resolveUserId(userId?: string): string {
140
202
  const id = userId || this.ctx.oxy.session.userId;
141
203
  if (!id) throw new Error('User not authenticated');
142
204
  return id;
@@ -6,7 +6,7 @@
6
6
  * token belongs to: validating it, listing the sessions beside it, signing out.
7
7
  */
8
8
  import { jwtDecode } from 'jwt-decode';
9
- import type { DeviceProof } from '@oxy.so/contracts';
9
+ import type { AccountActorChain, DeviceProof } from '@oxy.so/contracts';
10
10
  import type { OxyContext } from '../client/context';
11
11
  import type { ClientSession } from '../models/session';
12
12
  import type { User } from '../models/interfaces';
@@ -26,6 +26,8 @@ export interface SessionValidation {
26
26
  user: User;
27
27
  sessionId?: string;
28
28
  source?: string;
29
+ /** Who acted, and as whom, read off the session row (issue #1520). */
30
+ actor?: AccountActorChain;
29
31
  }
30
32
 
31
33
  interface AccessTokenClaims {
@@ -44,17 +44,14 @@ import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/c
44
44
  import { isNative as detectNative } from '../utils/platform';
45
45
  import { logger } from '../logger';
46
46
  import { computeIdentityTag } from '../utils/cacheKey';
47
- import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
47
+ import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm, refreshSharedDeviceArm } from '../session/refresh';
48
48
  import {
49
49
  establishIdentitySession,
50
50
  resolveIdentityPin,
51
51
  type IdentityBinding,
52
52
  } from '../session/identitySession';
53
53
  import type { IdentityPin } from '../session/identityPin';
54
- import {
55
- decideSharedDeviceJoin,
56
- type SharedDeviceCredentialStore,
57
- } from '../session/sharedDeviceCredential';
54
+ import type { SharedDeviceCredentialStore } from '../session/sharedDeviceCredential';
58
55
  import type { OxyServices } from '../OxyServices';
59
56
  import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
60
57
  import type { DeviceSessionState } from '@oxy.so/contracts';
@@ -178,6 +175,8 @@ export async function runSessionColdBoot(
178
175
  // Boot-local (not module-level) so it cannot leak across boots or break under
179
176
  // bundler re-evaluation.
180
177
  let signedOutReason: SignedOutReason = 'no_session';
178
+ let rejectedLocalHolder = false;
179
+ let accountRecoverySuperseded = false;
181
180
 
182
181
  // Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
183
182
  // (local, but storage-backed) read is covered by `overallDeadlineMs`, and
@@ -212,6 +211,7 @@ export async function runSessionColdBoot(
212
211
  steps.push({
213
212
  id: 'warm-token-plant',
214
213
  run: async () => {
214
+ if (identityBinding === null && isNative && await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
215
215
  const persisted = await store.load();
216
216
  if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
217
217
  return { kind: 'skip' };
@@ -287,6 +287,11 @@ export async function runSessionColdBoot(
287
287
  },
288
288
  };
289
289
  case 'invalid-secret': {
290
+ if (identityBinding === null && store.setAutomaticIdentitySignInSuppressed && await store.isAutomaticIdentitySignInSuppressed?.()) {
291
+ rejectedLocalHolder = true;
292
+ signedOutReason = 'no_session';
293
+ return {kind: 'skip'};
294
+ }
290
295
  // Stale/diverged secret — drop it so the mint lane stops firing. On
291
296
  // native the Commons-proof step below can still recover; on web this ends
292
297
  // signed out. Setting it undefined drops the key on the store's JSON
@@ -298,6 +303,7 @@ export async function runSessionColdBoot(
298
303
  return { kind: 'skip' };
299
304
  }
300
305
  case 'no-session':
306
+ rejectedLocalHolder = identityBinding === null;
301
307
  // Device known, no live session — authoritative signed-out. Keep the
302
308
  // secret (the device may sign in again).
303
309
  signedOutReason = 'no_session';
@@ -329,7 +335,9 @@ export async function runSessionColdBoot(
329
335
  );
330
336
  return { kind: 'skip' };
331
337
  case 'session-ended':
332
- // Signed out while the boot was minting; the arm planted nothing.
338
+ // The prior holder/epoch was superseded. This boot must not turn a
339
+ // late rejection into a fresh Commons sign-in; a new boot can reconcile.
340
+ if (identityBinding === null) accountRecoverySuperseded = true;
333
341
  return { kind: 'skip' };
334
342
  case 'no-secret':
335
343
  return { kind: 'skip' };
@@ -394,73 +402,16 @@ export async function runSessionColdBoot(
394
402
  // The adoption itself is local, but it is only worth committing alongside
395
403
  // a mint that proves the credential — so the whole lane is online-gated
396
404
  // like every other network step.
397
- enabled: () => isNative && !isOffline(),
405
+ enabled: () => isNative && !isOffline() && !accountRecoverySuperseded,
398
406
  run: async () => {
399
- const before = await store.load();
400
- const decision = decideSharedDeviceJoin(before, await sharedSlot.read());
401
- if (decision.action === 'skip') {
402
- logger.debug(
403
- `shared device credential not adopted (${decision.reason})`,
404
- { component: 'sessionColdBoot', method: 'shared-device-adopt' },
405
- );
406
- return { kind: 'skip' };
407
- }
408
-
409
- // Restore the store to exactly what it held before this lane touched it.
410
- // A credential we adopted and could not prove must not be left behind for
411
- // the next boot's mint lane to keep retrying.
412
- const revert = async (): Promise<void> => {
413
- if (before) {
414
- await store.save(before);
415
- } else {
416
- await store.clear();
417
- }
418
- };
419
-
420
- const adopted: PersistedAuthState = {
421
- // The mint fills both in from the device's live state; carrying the
422
- // previous session's ids into a different device session would be a
423
- // lie for however long the mint takes.
424
- sessionId: '',
425
- userId: '',
426
- deviceId: decision.credential.deviceId,
427
- deviceSecret: decision.credential.deviceSecret,
428
- };
429
- if (!(await store.save(adopted))) {
430
- logger.error(
431
- 'adopted the shared device credential but it could not be durably persisted — reverting',
432
- undefined,
433
- { component: 'sessionColdBoot', method: 'shared-device-adopt' },
434
- );
435
- await revert();
436
- return { kind: 'skip' };
437
- }
438
-
439
- const result = await refreshDeviceSecretArm({ oxy, store, pin: null });
407
+ const result = await refreshSharedDeviceArm({oxy, store, shared: sharedSlot, rejectedLocalHolder});
440
408
  if (result.status === 'ok') {
441
- return {
442
- kind: 'session',
443
- session: {
444
- sessionId: result.sessionId,
445
- userId: result.userId,
446
- accessToken: result.token,
447
- },
448
- };
409
+ return {kind: 'session', session: {
410
+ sessionId: result.sessionId, userId: result.userId, accessToken: result.token, state: result.state,
411
+ }};
449
412
  }
450
-
451
- if (result.status === 'invalid-secret') {
452
- // The one place we hold POSITIVE proof that the exact bytes in the
453
- // shared slot are dead — the server rejected them by name. Clearing it
454
- // signs nobody out (a credential the server does not recognise cannot
455
- // be minting for anyone) and it is what stops a dead credential from
456
- // blocking every future install: a stale slot owned by a different
457
- // `deviceId` is otherwise never overwritten, by design.
458
- await sharedSlot.clear();
459
- } else if (result.status === 'no-session') {
460
- signedOutReason = 'no_session';
461
- }
462
- await revert();
463
- return { kind: 'skip' };
413
+ if (result.status === 'no-session') signedOutReason = 'no_session';
414
+ return {kind: 'skip'};
464
415
  },
465
416
  });
466
417
  }
@@ -488,8 +439,10 @@ export async function runSessionColdBoot(
488
439
  id: 'commons-proof-signin',
489
440
  enabled: () => isNative && !isOffline(),
490
441
  run: async () => {
491
- const session = await oxy.auth.signInWithCommonsIdentity({ requestOptions: { retry: false } });
492
- if (!session?.accessToken) {
442
+ if (accountRecoverySuperseded || await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
443
+ const epoch = oxy.http.getSessionEpoch();
444
+ const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false, requestOptions: { retry: false } });
445
+ if (!session?.accessToken || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
493
446
  return { kind: 'skip' };
494
447
  }
495
448
  // `verifyChallenge` issues a deviceSecret; persist it so the next
@@ -505,6 +458,8 @@ export async function runSessionColdBoot(
505
458
  expiresAt: session.expiresAt,
506
459
  });
507
460
  }
461
+ if (oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
462
+ oxy.session.setAccessToken(session.accessToken);
508
463
  return {
509
464
  kind: 'session',
510
465
  session: {
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ export {
40
40
  ServiceAssetMetadataError,
41
41
  } from './OxyServices.errors';
42
42
  export type { HttpMethod } from './client/context';
43
- export type { RequestOptions, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler } from './HttpService';
43
+ export type { RequestOptions, ResponseRequest, ResponseTransport, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler } from './HttpService';
44
44
 
45
45
  // The namespaces — their classes (for typing) and their input/output types.
46
46
  export type { SessionApi, DeviceCredentialProvider, SessionValidation } from './api/session';
@@ -1,3 +1,4 @@
1
+ import type { MeteredGeneration } from '@oxy.so/contracts';
1
2
  /**
2
3
  * The Oxy inference client — one surface, two credential lanes (issue #972,
3
4
  * workstream 15).
@@ -263,6 +264,9 @@ export interface OxyGenerationReceipt {
263
264
  readonly settledAt: string;
264
265
  }
265
266
 
267
+ /** The endpoint returns either a customer receipt or internal technical usage. */
268
+ export type OxyGenerationRecord = OxyGenerationReceipt | MeteredGeneration;
269
+
266
270
  /**
267
271
  * Anything the inference API refused.
268
272
  *
@@ -688,12 +692,38 @@ export class OxyInferenceClient {
688
692
  */
689
693
  async getGeneration(
690
694
  id: string,
691
- options: { signal?: AbortSignal } = {},
695
+ options: { signal?: AbortSignal; delegatedUserId?: string } = {},
692
696
  ): Promise<OxyGenerationReceipt> {
693
- const body = await this.#request<{ data: OxyGenerationReceipt }>(
694
- 'GET',
695
- `/v1/generations/${encodeURIComponent(id)}`,
696
- { ...(options.signal === undefined ? {} : { signal: options.signal }) },
697
+ const record = await this.getGenerationRecord(id, options);
698
+ if (record.schemaVersion !== 1) {
699
+ throw new OxyInferenceProtocolError('Internal usage has no financial receipt. Use getGenerationRecord().', record.requestId);
700
+ }
701
+ return record;
702
+ }
703
+
704
+ /** Read a financial receipt (v1) or an internal technical record (v2). */
705
+ async getGenerationRecord(
706
+ id: string,
707
+ options: { signal?: AbortSignal; delegatedUserId?: string } = {},
708
+ ): Promise<OxyGenerationRecord> {
709
+ const body = await this.#request<{ data: OxyGenerationRecord }>(
710
+ 'GET', `/v1/generations/${encodeURIComponent(id)}`, options,
711
+ );
712
+ return body.data;
713
+ }
714
+
715
+ /**
716
+ * Recover the existing record using the original credential and key. This
717
+ * performs only GET; 404 is unknown/pending, never proof of zero cost and
718
+ * never an instruction to repeat decide(). Lost answers are not retained.
719
+ */
720
+ async getGenerationRecordByIdempotencyKey(
721
+ key: string,
722
+ options: { signal?: AbortSignal; delegatedUserId?: string } = {},
723
+ ): Promise<OxyGenerationRecord> {
724
+ if (key.length === 0 || key.length > 128) throw new Error('Invalid original idempotency key.');
725
+ const body = await this.#request<{ data: OxyGenerationRecord }>(
726
+ 'GET', '/v1/generations/by-idempotency-key', { ...options, idempotencyKey: key },
697
727
  );
698
728
  return body.data;
699
729
  }
@@ -22,6 +22,7 @@ export {
22
22
  } from './OxyInferenceClient';
23
23
  export type {
24
24
  OxyGenerationReceipt,
25
+ OxyGenerationRecord,
25
26
  OxyInferenceClientOptions,
26
27
  OxyInferenceCredential,
27
28
  OxyInferenceFetch,
@@ -26,6 +26,17 @@
26
26
  * Node only.
27
27
  */
28
28
  import { jwtDecode } from 'jwt-decode';
29
+ import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
30
+
31
+ /** Unverified cache metadata only: an old lifetime forces a new signed mint. */
32
+ function reusableServiceToken(token: string): boolean {
33
+ try {
34
+ const claims = jwtDecode<{ iat?: number; exp?: number }>(token);
35
+ return hasBoundedServiceTokenLifetime(claims) && typeof claims.exp === 'number'
36
+ && claims.exp > Math.floor(Date.now() / 1000);
37
+ } catch { return false; }
38
+ }
39
+
29
40
  import { loadNodeCrypto } from '@oxy.so/protocol';
30
41
  import { OxyServices, type OxyConfig } from '../OxyServices';
31
42
  import type { HttpMethod, ServiceLane } from '../client/context';
@@ -87,6 +98,21 @@ export interface OxyServerConfig extends OxyConfig {
87
98
  export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000;
88
99
 
89
100
  /** `POST /auth/service-token`. */
101
+ export interface ActingAsVerificationOptions {
102
+ /** Only explicit read-only callers may reuse authority; effects always pass false. */
103
+ cache?: boolean;
104
+ /** Taken from verified service claims by middleware, never from a user header. */
105
+ credentialId?: string;
106
+ ownerAccountId?: string;
107
+ environment?: string;
108
+ }
109
+
110
+ interface ActingAsGeneration {
111
+ epoch: bigint;
112
+ issued: number;
113
+ completed: number;
114
+ }
115
+
90
116
  export interface ServiceTokenResponse {
91
117
  token: string;
92
118
  expiresIn: number;
@@ -127,10 +153,10 @@ const TOKEN_REUSE_MARGIN_MS = 60_000;
127
153
 
128
154
  /** Bound on remembered `appId:userId` delegation answers. */
129
155
  const ACTING_AS_CACHE_MAX = 1000;
130
- /** A positive grant is reused for 5 minutes — the revocation latency window. */
131
- const ACTING_AS_GRANT_TTL_MS = 5 * 60 * 1000;
132
- /** A negative answer (or a failed lookup) is reused for 1 minute. */
133
- const ACTING_AS_DENIAL_TTL_MS = 60 * 1000;
156
+ /** Explicit read reuse only; effects always perform a fresh lookup. */
157
+ const ACTING_AS_GRANT_TTL_MS = 60_000;
158
+ /** Explicit authoritative read denials last ten seconds; failures are never cached. */
159
+ const ACTING_AS_DENIAL_TTL_MS = 10_000;
134
160
 
135
161
  export class OxyServer extends OxyServices {
136
162
  private readonly serviceTokens = new Map<string, ServiceTokenCacheEntry>();
@@ -138,6 +164,7 @@ export class OxyServer extends OxyServices {
138
164
  private serviceApiSecret: string | null = null;
139
165
  private readonly actingAs = new Map<string, { result: ServiceActingAsVerification | null; expiresAt: number }>();
140
166
  private readonly actingAsPending = new Map<string, Promise<ServiceActingAsVerification | null>>();
167
+ private readonly actingAsGenerations = new Map<string, ActingAsGeneration>();
141
168
  /** Public keys only; never private material. */
142
169
  private readonly jwksCache: ServiceTokenJwksCache = { keys: new Map(), expiresAt: 0, lastAttemptAt: 0 };
143
170
  /**
@@ -180,7 +207,7 @@ export class OxyServer extends OxyServices {
180
207
  },
181
208
  jwksCache: this.jwksCache,
182
209
  validateSession: (sessionId, options) => this.session.validate(sessionId, options),
183
- verifyActingAs: (appId, userId) => this.verifyActingAs(appId, userId),
210
+ verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
184
211
  });
185
212
  }
186
213
 
@@ -277,7 +304,7 @@ export class OxyServer extends OxyServices {
277
304
  });
278
305
  throw new ServiceCredentialMismatchError();
279
306
  }
280
- if (entry.token && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
307
+ if (entry.token && reusableServiceToken(entry.token) && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
281
308
  return entry.token;
282
309
  }
283
310
  if (entry.pending) {
@@ -344,32 +371,45 @@ export class OxyServer extends OxyServices {
344
371
  // ── Delegation and account events ────────────────────────────────────────
345
372
 
346
373
  /**
347
- * Whether service app `appId` holds an active delegation grant to act for
348
- * `userId`; the grant's scopes, or `null`. Used by `middleware.auth()` for
349
- * `X-Oxy-User-Id`.
350
- *
351
- * Answers are cached per `appId:userId` (a grant for 5 minutes — the
352
- * revocation window; a denial or failed lookup for 1 minute), bounded to the
353
- * 1000 most recent pairs, and concurrent lookups of one pair share a single
354
- * request.
374
+ * Resolve authority live by default. Explicit read-only reuse lasts at most
375
+ * 60s positive / 10s negative, anchored to request start; errors never cache.
376
+ * Effects do not share an older in-flight read. Durable epoch plus local
377
+ * completion ordering prevents ABA and late positive replies overwriting a
378
+ * newer refusal even when app/credential revocation leaves epoch unchanged.
355
379
  */
356
- async verifyActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null> {
357
- const cacheKey = `${appId}:${userId}`;
358
- const cached = this.actingAs.get(cacheKey);
359
- if (cached && cached.expiresAt > Date.now()) {
360
- // Refresh recency for the LRU bound.
361
- this.actingAs.delete(cacheKey);
362
- this.actingAs.set(cacheKey, cached);
363
- return cached.result;
364
- }
365
- const inflight = this.actingAsPending.get(cacheKey);
366
- if (inflight) return inflight;
367
-
368
- const lookup = this.lookupActingAs(appId, userId).finally(() => {
369
- this.actingAsPending.delete(cacheKey);
370
- });
380
+ async verifyActingAs(
381
+ appId: string, userId: string, options: ActingAsVerificationOptions = {},
382
+ ): Promise<ServiceActingAsVerification | null> {
383
+ const pair = JSON.stringify([appId, userId]);
384
+ const cacheKey = JSON.stringify([appId, userId, options.credentialId, options.ownerAccountId, options.environment]);
385
+ if (options.cache === true) {
386
+ const cached = this.actingAs.get(cacheKey);
387
+ if (cached && cached.expiresAt > Date.now()) {
388
+ this.actingAs.delete(cacheKey);
389
+ this.actingAs.set(cacheKey, cached);
390
+ return cached.result;
391
+ }
392
+ const pending = this.actingAsPending.get(cacheKey);
393
+ if (pending) return pending;
394
+ }
395
+ let generation = this.actingAsGenerations.get(pair);
396
+ if (!generation) {
397
+ generation = { epoch: 0n, issued: 0, completed: 0 };
398
+ this.actingAsGenerations.set(pair, generation);
399
+ if (this.actingAsGenerations.size > ACTING_AS_CACHE_MAX) {
400
+ const oldest = this.actingAsGenerations.keys().next().value;
401
+ if (oldest !== undefined) this.actingAsGenerations.delete(oldest);
402
+ }
403
+ }
404
+ const serial = ++generation.issued;
405
+ const startedAt = Date.now();
406
+ const lookup = this.lookupActingAs(appId, userId, options, pair, cacheKey, generation, serial, startedAt);
407
+ if (options.cache !== true) return lookup;
371
408
  this.actingAsPending.set(cacheKey, lookup);
372
- return lookup;
409
+ try { return await lookup; }
410
+ finally {
411
+ if (this.actingAsPending.get(cacheKey) === lookup) this.actingAsPending.delete(cacheKey);
412
+ }
373
413
  }
374
414
 
375
415
  /**
@@ -457,7 +497,7 @@ export class OxyServer extends OxyServices {
457
497
  /** A token obtained by attestation, cached like a credential's (one identity per process). */
458
498
  private async workloadServiceToken(): Promise<string> {
459
499
  const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
460
- if (entry?.token && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
500
+ if (entry?.token && reusableServiceToken(entry.token) && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
461
501
  if (entry?.pending) return entry.pending;
462
502
 
463
503
  const seeded = entry ?? { token: '', expiresAt: 0, secretBuf: Buffer.alloc(0), pending: null, apiKey: WORKLOAD_CACHE_KEY };
@@ -486,41 +526,62 @@ export class OxyServer extends OxyServices {
486
526
  }
487
527
  }
488
528
 
489
- private async lookupActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null> {
529
+ private async lookupActingAs(
530
+ appId: string, userId: string, options: ActingAsVerificationOptions,
531
+ pair: string, cacheKey: string, generation: ActingAsGeneration, serial: number, startedAt: number,
532
+ ): Promise<ServiceActingAsVerification | null> {
490
533
  try {
491
- // The verify endpoint admits only a platform-trusted caller, so this
492
- // carries the VERIFIER's own service token. No retry and a short timeout:
493
- // it runs inside request-handling middleware, and a retry loop would
494
- // multiply the latency of every delegated request. A verifier with no
495
- // credential throws here and is refused below — a host that cannot prove
496
- // who it is has no business learning who delegated to whom.
497
534
  const serviceToken = await this.serviceToken();
498
535
  const result = await this.request<ServiceActingAsVerification>(
499
- 'GET',
500
- '/internal/service-acting-as/verify',
501
- { appId, userId },
536
+ 'GET', '/internal/service-acting-as/verify',
537
+ { appId, userId, ...(options.credentialId ? { credentialId: options.credentialId,
538
+ ownerAccountId: options.ownerAccountId, environment: options.environment } : {}) },
502
539
  { cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}` } },
503
540
  );
504
- const verified: ServiceActingAsVerification | null = result?.authorized
505
- ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
506
- : null;
507
- this.rememberActingAs(`${appId}:${userId}`, verified, ACTING_AS_GRANT_TTL_MS);
541
+ // A deadline bounds how long an already old response can authorize. It
542
+ // is not by itself a measurement of end-to-end revocation latency.
543
+ if (this.actingAsGenerations.get(pair) !== generation || serial < generation.completed) return null;
544
+ generation.completed = serial;
545
+ this.clearActingAsPair(appId, userId);
546
+ if (Date.now() - startedAt >= 5000) return null;
547
+ const rawEpoch = result?.epoch;
548
+ if (typeof rawEpoch !== 'string' || !/^(?:0|[1-9][0-9]{0,18})$/.test(rawEpoch)) return null;
549
+ const epoch = BigInt(rawEpoch);
550
+ if (epoch > 9223372036854775807n) return null;
551
+ if (typeof result.authorized !== 'boolean' || !Array.isArray(result.scopes)
552
+ || !result.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope.trim() === scope)
553
+ || new Set(result.scopes).size !== result.scopes.length
554
+ || (!result.authorized && result.scopes.length !== 0)) return null;
555
+ const older = epoch < generation.epoch;
556
+ if (epoch > generation.epoch) generation.epoch = epoch;
557
+ if (result.authorized && older) return null;
558
+ const verified: ServiceActingAsVerification | null = result.authorized === true
559
+ ? { authorized: true, scopes: result.scopes, epoch: rawEpoch } : null;
560
+ if (options.cache === true) this.rememberActingAs(cacheKey, verified,
561
+ startedAt + (verified ? ACTING_AS_GRANT_TTL_MS : ACTING_AS_DENIAL_TTL_MS));
508
562
  return verified;
509
563
  } catch (error) {
510
- logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
511
- component: 'auth',
512
- method: 'verifyActingAs',
513
- appId,
514
- userId,
564
+ if (this.actingAsGenerations.get(pair) === generation && serial >= generation.completed) {
565
+ generation.completed = serial;
566
+ this.clearActingAsPair(appId, userId);
567
+ }
568
+ logger.warn('[oxy.auth] verifyActingAs lookup failed — not cached', {
569
+ component: 'auth', method: 'verifyActingAs', appId, userId,
515
570
  }, error);
516
- this.rememberActingAs(`${appId}:${userId}`, null, ACTING_AS_DENIAL_TTL_MS);
517
571
  return null;
518
572
  }
519
573
  }
520
574
 
521
- private rememberActingAs(cacheKey: string, result: ServiceActingAsVerification | null, ttlMs: number): void {
575
+ private clearActingAsPair(appId: string, userId: string): void {
576
+ for (const key of this.actingAs.keys()) {
577
+ const [cachedApp, cachedUser] = JSON.parse(key) as [string, string];
578
+ if (cachedApp === appId && cachedUser === userId) this.actingAs.delete(key);
579
+ }
580
+ }
581
+
582
+ private rememberActingAs(cacheKey: string, result: ServiceActingAsVerification | null, expiresAt: number): void {
522
583
  this.actingAs.delete(cacheKey);
523
- this.actingAs.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
584
+ this.actingAs.set(cacheKey, { result, expiresAt });
524
585
  if (this.actingAs.size > ACTING_AS_CACHE_MAX) {
525
586
  const oldest = this.actingAs.keys().next().value;
526
587
  if (oldest !== undefined) this.actingAs.delete(oldest);
@@ -0,0 +1,28 @@
1
+ /** Autonomous entry helper. The signer owns its private key (for example in a KMS). */
2
+ import { buildAgentProofMessage, type LoginResult } from '@oxy.so/contracts';
3
+ import { normalizeSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
4
+ import type { OxyServices } from '../OxyServices';
5
+
6
+ export interface AgentAccountSigner {
7
+ /** Compressed or uncompressed SEC1 secp256k1 public key; no private-key field exists. */
8
+ publicKey: string;
9
+ signMessage(message: string): Promise<string>;
10
+ }
11
+
12
+ /** Explicit sign-in; no timer, automatic escalation or service credential lane. */
13
+ export async function signInAgentAccount(options: {
14
+ client: Pick<OxyServices, 'auth'>;
15
+ accountId: string;
16
+ signer: AgentAccountSigner;
17
+ }): Promise<LoginResult> {
18
+ const { client, accountId, signer } = options;
19
+ const publicKey = normalizeSecp256k1PublicKey(signer.publicKey.trim());
20
+ const claims = await client.auth.agent.requestChallenge(publicKey);
21
+ const timestamp = Date.now();
22
+ if (claims.action !== 'agent_signin' || claims.accountId !== accountId || claims.actorId !== accountId
23
+ || normalizeSecp256k1PublicKey(claims.publicKey) !== publicKey || !claims.authMethodId || claims.expiresAt <= timestamp) {
24
+ throw new Error('Agent challenge does not match this account and signer');
25
+ }
26
+ const signature = await signer.signMessage(buildAgentProofMessage(claims, timestamp));
27
+ return client.auth.agent.verify(publicKey, { challenge: claims.challenge, timestamp, signature });
28
+ }