@oxyhq/core 21.0.0 → 21.0.2

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 (62) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +47 -8
  3. package/dist/cjs/i18n/locales/en-US.json +7 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -2
  7. package/dist/cjs/index.js +8 -1
  8. package/dist/cjs/inference/OxyInferenceClient.js +330 -0
  9. package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
  10. package/dist/cjs/mixins/OxyServices.inference.js +59 -0
  11. package/dist/cjs/mixins/OxyServices.utility.js +18 -6
  12. package/dist/cjs/mixins/index.js +6 -0
  13. package/dist/cjs/server/auth.js +76 -0
  14. package/dist/cjs/server/cors.js +84 -15
  15. package/dist/cjs/server/index.js +5 -1
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/HttpService.js +47 -8
  18. package/dist/esm/i18n/locales/en-US.json +7 -2
  19. package/dist/esm/i18n/locales/es-ES.json +7 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +7 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +7 -2
  22. package/dist/esm/index.js +4 -0
  23. package/dist/esm/inference/OxyInferenceClient.js +325 -0
  24. package/dist/esm/mixins/OxyServices.accounts.js +5 -72
  25. package/dist/esm/mixins/OxyServices.inference.js +56 -0
  26. package/dist/esm/mixins/OxyServices.utility.js +18 -6
  27. package/dist/esm/mixins/index.js +6 -0
  28. package/dist/esm/server/auth.js +72 -0
  29. package/dist/esm/server/cors.js +82 -15
  30. package/dist/esm/server/index.js +1 -1
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/HttpService.d.ts +39 -1
  33. package/dist/types/index.d.ts +3 -1
  34. package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
  35. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
  36. package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
  37. package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
  38. package/dist/types/mixins/index.d.ts +2 -1
  39. package/dist/types/server/auth.d.ts +80 -0
  40. package/dist/types/server/cors.d.ts +41 -0
  41. package/dist/types/server/index.d.ts +2 -2
  42. package/package.json +2 -2
  43. package/src/HttpService.ts +50 -10
  44. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
  45. package/src/i18n/locales/en-US.json +7 -2
  46. package/src/i18n/locales/es-ES.json +7 -2
  47. package/src/index.ts +19 -7
  48. package/src/inference/OxyInferenceClient.ts +590 -0
  49. package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
  50. package/src/mixins/OxyServices.accounts.ts +75 -176
  51. package/src/mixins/OxyServices.inference.ts +57 -0
  52. package/src/mixins/OxyServices.utility.ts +58 -14
  53. package/src/mixins/__tests__/accounts.test.ts +57 -102
  54. package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
  55. package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
  56. package/src/mixins/index.ts +8 -0
  57. package/src/server/__tests__/cors.socket.test.ts +225 -0
  58. package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
  59. package/src/server/auth.ts +118 -0
  60. package/src/server/cors.ts +87 -12
  61. package/src/server/index.ts +6 -0
  62. package/src/session/__tests__/accountDialogShape.test.ts +118 -0
@@ -288,72 +288,6 @@ export interface TransferAccountOwnershipInput {
288
288
  userId: string;
289
289
  }
290
290
 
291
- // ---------------------------------------------------------------------------
292
- // Bot (account) service-credential types
293
- // ---------------------------------------------------------------------------
294
-
295
- /** Credential kind. Account (bot) credentials are always `service` tokens. */
296
- export type AccountCredentialType = 'service';
297
-
298
- /** Deployment environment a bot credential is scoped to. */
299
- export type AccountCredentialEnvironment = 'development' | 'staging' | 'production';
300
-
301
- /** Bot credential lifecycle status. */
302
- export type AccountCredentialStatus = 'active' | 'deprecated' | 'revoked';
303
-
304
- /** Input accepted by `createAccountCredential`. Credential `type` is always `service`. */
305
- export interface CreateAccountCredentialInput {
306
- name: string;
307
- environment: AccountCredentialEnvironment;
308
- scopes?: string[];
309
- }
310
-
311
- /**
312
- * Client-facing AccountCredential shape (a bot account's service token). The raw
313
- * secret is NEVER part of this shape — it is returned exactly once, separately,
314
- * at creation/rotation.
315
- */
316
- export interface AccountCredential {
317
- _id: string;
318
- /** The bot account this credential authenticates as (account `_id`). */
319
- accountId: string;
320
- name: string;
321
- publicKey: string;
322
- type: AccountCredentialType;
323
- environment: AccountCredentialEnvironment;
324
- scopes: string[];
325
- status: AccountCredentialStatus;
326
- lastUsedAt?: string;
327
- expiresAt?: string;
328
- /**
329
- * Audit link to the credential this one was rotated FROM. Populated on
330
- * credentials created via rotation; absent on original credentials.
331
- */
332
- rotatedFromCredentialId?: string;
333
- createdByUserId: string;
334
- createdAt: string;
335
- updatedAt: string;
336
- }
337
-
338
- /** Result of creating a bot credential — `secret` is returned ONCE. */
339
- export interface AccountCredentialWithSecret {
340
- credential: AccountCredential;
341
- secret: string;
342
- }
343
-
344
- /**
345
- * Result of rotating a bot credential. Extends the create result with audit
346
- * fields: the new plaintext `secret` is returned ONCE, plus `rotatedFrom` (the
347
- * previous credential's `credentialId`) and `graceExpiresAt` (ISO string marking
348
- * when the old credential stops being honoured during the rotation grace window).
349
- */
350
- export interface RotateAccountCredentialResult extends AccountCredentialWithSecret {
351
- /** The previous credential's `credentialId` that this rotation supersedes. */
352
- rotatedFrom: string;
353
- /** ISO timestamp at which the rotated-from credential's grace window ends. */
354
- graceExpiresAt: string;
355
- }
356
-
357
291
  // ---------------------------------------------------------------------------
358
292
  // Application (owned by an account) types
359
293
  // ---------------------------------------------------------------------------
@@ -367,8 +301,18 @@ export type ApplicationType = 'first_party' | 'third_party' | 'internal' | 'syst
367
301
  /** Lifecycle status of an application. */
368
302
  export type ApplicationStatus = 'active' | 'suspended' | 'deleted' | 'pending_review';
369
303
 
370
- /** OAuth credential kind. `service` credentials mint service tokens. */
371
- export type ApplicationCredentialType = 'public' | 'confidential' | 'service';
304
+ /**
305
+ * Credential kind.
306
+ *
307
+ * The first three are OAuth clients: the `oxy_dk_…` `publicKey` is the
308
+ * `client_id`, and any secret is presented BESIDE it. `service` credentials
309
+ * additionally mint service tokens.
310
+ *
311
+ * `machine` is the OpenAI-SDK-compatible API key (issue #972 §2.3): its
312
+ * credential material is ONE `oxy_sk_…` bearer string returned in `token`
313
+ * exactly once on create/rotate, never in `secret`.
314
+ */
315
+ export type ApplicationCredentialType = 'public' | 'confidential' | 'service' | 'machine';
372
316
 
373
317
  /** Deployment environment an application credential is scoped to. */
374
318
  export type ApplicationEnvironment = 'development' | 'staging' | 'production';
@@ -428,6 +372,12 @@ export interface ApplicationCredential {
428
372
  applicationId: string;
429
373
  name: string;
430
374
  publicKey: string;
375
+ /**
376
+ * `oxy_sk_<id>` — the PUBLIC lookup half of a `machine` credential's bearer
377
+ * token, present only on that type. Safe to render: the secret half is 256
378
+ * bits that were shown exactly once and are never returned again.
379
+ */
380
+ tokenPrefix?: string;
431
381
  type: ApplicationCredentialType;
432
382
  environment: ApplicationEnvironment;
433
383
  scopes: string[];
@@ -486,25 +436,70 @@ export interface CreateApplicationCredentialInput {
486
436
  type: ApplicationCredentialType;
487
437
  environment: ApplicationEnvironment;
488
438
  scopes?: string[];
439
+ /**
440
+ * Lifetime of a `machine` credential, in seconds — 60 to 730 days. Omit for a
441
+ * key that does not expire on its own.
442
+ *
443
+ * **`machine` only.** On every other credential type `expires_at` means the
444
+ * rotation grace deadline, so a caller setting it at creation would make a
445
+ * brand-new credential indistinguishable from a rotated one. The server
446
+ * REJECTS it for those types rather than ignoring it, so sending it with the
447
+ * wrong `type` is a 400, not a silently dropped field.
448
+ */
449
+ expiresInSeconds?: number;
450
+ }
451
+
452
+ /** Input accepted by `rotateAppCredential`. */
453
+ export interface RotateApplicationCredentialInput {
454
+ /**
455
+ * How long the superseded `machine` token keeps working, in seconds — 1 to 30
456
+ * days. Omitting it revokes the previous token the instant the replacement is
457
+ * minted, which is the safe default for a leaked key.
458
+ *
459
+ * **`machine` only, and opt-in.** `confidential`/`service` credentials always
460
+ * retire on the platform's fixed seven-day grace and the server REJECTS this
461
+ * field for them, so their contract is unchanged.
462
+ */
463
+ graceSeconds?: number;
489
464
  }
490
465
 
491
- /** Result of creating an application credential — `secret` is returned ONCE. */
466
+ /**
467
+ * Result of creating an application credential — credential material is returned
468
+ * ONCE and can never be read back.
469
+ *
470
+ * Exactly one of the two fields carries it, decided by
471
+ * {@link ApplicationCredentialType}: `secret` for a `confidential`/`service`
472
+ * client, `token` for a `machine` API key, and NEITHER for a `public` client
473
+ * (`secret` is `null`). They are separate fields rather than one, so a surface
474
+ * that renders "the secret" cannot silently render an API key's bearer token
475
+ * under the wrong label, or a `null` where a token should be.
476
+ */
492
477
  export interface ApplicationCredentialWithSecret {
493
478
  credential: ApplicationCredential;
494
- secret: string;
479
+ /** The OAuth client secret. `null` for `public` and `machine` credentials. */
480
+ secret: string | null;
481
+ /** The full `oxy_sk_…` bearer token. Present ONLY for a `machine` credential. */
482
+ token?: string;
495
483
  }
496
484
 
497
485
  /**
498
486
  * Result of rotating an application credential. Extends the create result with
499
- * audit fields: the new plaintext `secret` is returned ONCE, plus `rotatedFrom`
500
- * (the previous credential's `credentialId`) and `graceExpiresAt` (ISO string
501
- * marking when the old credential stops being honoured during the grace window).
487
+ * audit fields: the new credential material is returned ONCE, plus `rotatedFrom`
488
+ * (the previous credential's `credentialId`) and `graceExpiresAt`.
502
489
  */
503
490
  export interface RotateApplicationCredentialResult extends ApplicationCredentialWithSecret {
504
491
  /** The previous credential's `credentialId` that this rotation supersedes. */
505
492
  rotatedFrom: string;
506
- /** ISO timestamp at which the rotated-from credential's grace window ends. */
507
- graceExpiresAt: string;
493
+ /**
494
+ * ISO timestamp at which the rotated-from credential stops being honoured, or
495
+ * `null` when no grace window was configured and it was revoked outright.
496
+ *
497
+ * Nullable because a `machine` credential's grace is OPT-IN (issue #972 §2.3):
498
+ * rotating an API key without asking for a window kills the old token
499
+ * immediately, and there is then no deadline to report. The OAuth/service
500
+ * types always carry their fixed seven-day deadline.
501
+ */
502
+ graceExpiresAt: string | null;
508
503
  }
509
504
 
510
505
  /** Time window for application usage statistics. */
@@ -1009,106 +1004,6 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1009
1004
  }
1010
1005
  }
1011
1006
 
1012
- // =========================================================================
1013
- // Bot (account) service credentials — /accounts/:id/credentials
1014
- // =========================================================================
1015
-
1016
- /**
1017
- * List a bot account's service credentials. The response NEVER includes
1018
- * secrets.
1019
- * @param accountId - The account's Mongo `_id`.
1020
- */
1021
- async listAccountCredentials(accountId: string): Promise<AccountCredential[]> {
1022
- try {
1023
- const res = await this.makeRequest<{ credentials?: AccountCredential[] }>(
1024
- 'GET',
1025
- `/accounts/${encodeURIComponent(accountId)}/credentials`,
1026
- undefined,
1027
- { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
1028
- );
1029
- return res.credentials ?? [];
1030
- } catch (error) {
1031
- throw this.handleError(error);
1032
- }
1033
- }
1034
-
1035
- /**
1036
- * Create a service credential for a bot account. The plaintext `secret` is
1037
- * returned exactly ONCE; the server stores only a hash and will never return
1038
- * it again.
1039
- * @param accountId - The account's Mongo `_id`.
1040
- * @param data - Credential configuration (`type` is always `service`).
1041
- */
1042
- async createAccountCredential(
1043
- accountId: string,
1044
- data: CreateAccountCredentialInput,
1045
- ): Promise<AccountCredentialWithSecret> {
1046
- try {
1047
- const result = await this.makeRequest<AccountCredentialWithSecret>(
1048
- 'POST',
1049
- `/accounts/${encodeURIComponent(accountId)}/credentials`,
1050
- data,
1051
- { cache: false },
1052
- );
1053
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
1054
- return result;
1055
- } catch (error) {
1056
- throw this.handleError(error);
1057
- }
1058
- }
1059
-
1060
- /**
1061
- * Rotate a bot credential's secret. The new plaintext `secret` is returned
1062
- * exactly ONCE, along with audit fields: `rotatedFrom` (the previous
1063
- * credentialId) and `graceExpiresAt` (ISO string for the grace window during
1064
- * which the old credential is still honoured).
1065
- * @param accountId - The account's Mongo `_id`.
1066
- * @param credentialId - The credential's Mongo `_id`.
1067
- */
1068
- async rotateAccountCredential(
1069
- accountId: string,
1070
- credentialId: string,
1071
- ): Promise<RotateAccountCredentialResult> {
1072
- try {
1073
- const result = await this.makeRequest<RotateAccountCredentialResult>(
1074
- 'POST',
1075
- `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
1076
- undefined,
1077
- { cache: false },
1078
- );
1079
- // Rotation changes credential status/audit fields surfaced by the list.
1080
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
1081
- return result;
1082
- } catch (error) {
1083
- throw this.handleError(error);
1084
- }
1085
- }
1086
-
1087
- /**
1088
- * Revoke a bot credential (`status='revoked'`). Revoked credentials can no
1089
- * longer authenticate.
1090
- * @param accountId - The account's Mongo `_id`.
1091
- * @param credentialId - The credential's Mongo `_id`.
1092
- */
1093
- async revokeAccountCredential(
1094
- accountId: string,
1095
- credentialId: string,
1096
- ): Promise<AccountSuccessResult> {
1097
- try {
1098
- const result = await this.makeRequest<AccountSuccessResult>(
1099
- 'DELETE',
1100
- `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}`,
1101
- undefined,
1102
- { cache: false },
1103
- );
1104
- // Revocation flips the credential's status in the cached list.
1105
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
1106
- return result;
1107
- } catch (error) {
1108
- throw this.handleError(error);
1109
- }
1110
- }
1111
-
1112
1007
  // =========================================================================
1113
1008
  // Applications owned by an account — /applications
1114
1009
  // =========================================================================
@@ -1273,16 +1168,20 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1273
1168
  * which the old credential is still honoured).
1274
1169
  * @param applicationId - The application's Mongo `_id`.
1275
1170
  * @param credentialId - The credential's Mongo `_id`.
1171
+ * @param options - `graceSeconds` keeps a superseded `machine` token working
1172
+ * for that long. Omitted, the previous token dies the moment the
1173
+ * replacement is minted.
1276
1174
  */
1277
1175
  async rotateAppCredential(
1278
1176
  applicationId: string,
1279
1177
  credentialId: string,
1178
+ options?: RotateApplicationCredentialInput,
1280
1179
  ): Promise<RotateApplicationCredentialResult> {
1281
1180
  try {
1282
1181
  const result = await this.makeRequest<RotateApplicationCredentialResult>(
1283
1182
  'POST',
1284
1183
  `/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
1285
- undefined,
1184
+ options,
1286
1185
  { cache: false },
1287
1186
  );
1288
1187
  // Rotation changes credential status/audit fields surfaced by the list.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The inference API, reached with whatever bearer this session already holds
3
+ * (issue #972, workstream 15).
4
+ *
5
+ * ```typescript
6
+ * const models = await oxyServices.inference().listModels();
7
+ * ```
8
+ *
9
+ * One method, and it is a FACTORY rather than a set of inference methods on
10
+ * `OxyServices`. The calls themselves live once, in
11
+ * {@link OxyInferenceClient} — which an external developer holding only an
12
+ * `oxy_sk_…` machine key constructs directly, with no Oxy session anywhere in
13
+ * the picture. Declaring the same calls a second time here would give the
14
+ * ecosystem two spellings of one request, and only one of them would stay
15
+ * correct.
16
+ *
17
+ * This is the reasoning `createLinkedClient` is already built on: the plumbing
18
+ * that binds an Oxy bearer to a client belongs in core, once, rather than in
19
+ * each app.
20
+ *
21
+ * The credential is a FUNCTION, not the current token: a session bearer rotates
22
+ * on refresh and on account switch, and a client that captured one at
23
+ * construction would start answering 401 an hour into the process's life.
24
+ */
25
+
26
+ import { OxyInferenceClient } from '../inference/OxyInferenceClient';
27
+ import type { OxyServicesBase } from '../OxyServices.base';
28
+
29
+ export function OxyServicesInferenceMixin<T extends typeof OxyServicesBase>(Base: T) {
30
+ return class extends Base {
31
+ /** @internal Memoized so repeated calls return one object identity. */
32
+ _inferenceClient: OxyInferenceClient | null = null;
33
+
34
+ /**
35
+ * The inference client for this session.
36
+ *
37
+ * Bound to this instance's base URL and to `getAccessToken()`, so it
38
+ * follows every refresh, sign-in and account switch without being
39
+ * rebuilt.
40
+ *
41
+ * A service-authenticated process wants a different credential and
42
+ * builds {@link OxyInferenceClient} directly:
43
+ * `new OxyInferenceClient({ credential: () => oxy.getServiceToken() })`.
44
+ * The mint is asynchronous and cached, which is exactly what a
45
+ * credential function is for.
46
+ */
47
+ inference(): OxyInferenceClient {
48
+ if (this._inferenceClient === null) {
49
+ this._inferenceClient = new OxyInferenceClient({
50
+ baseURL: this.getBaseURL(),
51
+ credential: () => this.getAccessToken(),
52
+ });
53
+ }
54
+ return this._inferenceClient;
55
+ }
56
+ };
57
+ }
@@ -22,6 +22,7 @@ interface JwtPayload {
22
22
  type?: string;
23
23
  appId?: string;
24
24
  credentialId?: string;
25
+ ownerAccountId?: string;
25
26
  appName?: string;
26
27
  scopes?: string[];
27
28
  aud?: string | string[];
@@ -49,9 +50,21 @@ export interface ServiceActingAsVerification {
49
50
 
50
51
  /**
51
52
  * Service app metadata attached to requests authenticated with service tokens.
52
- * `scopes` reflects the scopes granted to the app at signup time (from the
53
- * `Application.scopes` field); route-level checks can require additional
54
- * scope-narrowing via `requireScope()`.
53
+ *
54
+ * Every field comes from the token's SIGNED payload and is populated only after
55
+ * the signature, `iss`/`aud`/`type` binding and expiry all pass — so a verifier
56
+ * holding this object can name the responsible principals without a lookup of
57
+ * its own. Together with `credentialId` and `ownerAccountId` it is the canonical
58
+ * attribution tuple of ADR 0007 minus the delegated user.
59
+ *
60
+ * `scopes` are the EFFECTIVE scopes: the credential's own scopes intersected
61
+ * with the owning application's grant at mint time (the API's `intersectScopes`
62
+ * is the single authority for that intersection — nothing re-intersects here).
63
+ * Route-level checks narrow further via `requireScope()`.
64
+ *
65
+ * A delegated end user is NOT a field of this type, and must never become one.
66
+ * It lives in `req.serviceActingAs` / `req.userId`, is authorised per request,
67
+ * and is attribution only.
55
68
  */
56
69
  export interface ServiceApp {
57
70
  appId: string;
@@ -59,6 +72,12 @@ export interface ServiceApp {
59
72
  scopes: string[];
60
73
  /** The credentialId of the specific service credential that minted this token. */
61
74
  credentialId: string;
75
+ /**
76
+ * The Oxy account that owns `appId` and is financially responsible for it.
77
+ * The BILLING principal — never a user id, and never the delegated
78
+ * `X-Oxy-User-Id` (ADR 0007).
79
+ */
80
+ ownerAccountId: string;
62
81
  /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
63
82
  environment: OxyServiceEnvironment;
64
83
  }
@@ -122,11 +141,24 @@ interface AuthMiddlewareOptions {
122
141
  * When provided, service tokens will be cryptographically verified.
123
142
  * When omitted, service tokens will be rejected (secure default).
124
143
  *
125
- * **Migration note (>=1.11.14):** the Oxy API now signs service tokens
126
- * with a dedicated `SERVICE_TOKEN_SECRET` distinct from `ACCESS_TOKEN_SECRET`.
127
- * Pass that value here. If you keep passing the access-token secret you will
128
- * still verify ALL signed-by-Oxy tokens (which is the whole class of bug
129
- * H4 was supposed to prevent DO NOT do that in production).
144
+ * **The only value that works is `ACCESS_TOKEN_SECRET`, and you should not
145
+ * want to hold it see issue #987 and ADR 0012.** The Oxy API signs service
146
+ * tokens with `ACCESS_TOKEN_SECRET` (`packages/api/src/routes/auth.ts`),
147
+ * which is also the key that signs every user access token. There is no
148
+ * separate service-token secret: earlier revisions of this comment named a
149
+ * `SERVICE_TOKEN_SECRET` that has never existed in the API, in any workflow or
150
+ * in any task definition, and passing one would fail every verification.
151
+ *
152
+ * The consequence to hold onto: the scheme is symmetric, so a host that can
153
+ * VERIFY a service token can also MINT one — including a user access token.
154
+ * **Local verification is therefore appropriate only inside the Oxy API's own
155
+ * trust boundary, and no service outside it holds this key today.** Do not be
156
+ * the first: if you need to verify Oxy service tokens from another service,
157
+ * follow #987 rather than copying the secret.
158
+ *
159
+ * `docs/adr/0012-service-token-signing-key-model.md` records the decision to
160
+ * retire this option in favour of asymmetric signing against a published
161
+ * JWKS, at which point it is removed rather than deprecated.
130
162
  */
131
163
  jwtSecret?: string;
132
164
  /**
@@ -266,7 +298,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
266
298
  * additionally checked for `aud`, `iss`, and `type` claims to prevent
267
299
  * cross-token-type confusion attacks.
268
300
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
269
- * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
301
+ * direct access to `ACCESS_TOKEN_SECRET`.
270
302
  *
271
303
  * **Why session-less user tokens are refused rather than trusted:**
272
304
  * every user access token the Oxy API issues carries a `sessionId` (see
@@ -298,7 +330,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
298
330
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
299
331
  *
300
332
  * // Protect all routes under /protected
301
- * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
333
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
302
334
  *
303
335
  * // Access user in route handler
304
336
  * app.get('/protected/me', (req, res) => {
@@ -312,7 +344,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
312
344
  * app.use('/public', oxy.auth({ optional: true }));
313
345
  *
314
346
  * // Require a specific scope on a service-token-protected route
315
- * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
347
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
316
348
  * ```
317
349
  *
318
350
  * @param options Optional configuration
@@ -490,14 +522,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
490
522
  return res.status(401).json(error);
491
523
  }
492
524
 
493
- // Validate required service token fields
525
+ // Validate required service token fields. All of them are
526
+ // required, `ownerAccountId` included: an optional billing
527
+ // principal is one fallback away from being resolved from the
528
+ // delegated user, which is the exact confusion ADR 0007 forbids.
494
529
  const appId = decoded.appId;
495
530
  const credentialId = decoded.credentialId;
531
+ const ownerAccountId = decoded.ownerAccountId;
496
532
  const environment = decoded.environment;
497
533
  if (
498
534
  !appId ||
499
535
  typeof credentialId !== 'string' ||
500
536
  credentialId.length === 0 ||
537
+ typeof ownerAccountId !== 'string' ||
538
+ ownerAccountId.length === 0 ||
501
539
  !isOxyServiceEnvironment(environment)
502
540
  ) {
503
541
  if (optional) {
@@ -538,6 +576,11 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
538
576
  return res.status(403).json(error);
539
577
  }
540
578
 
579
+ // ATTRIBUTION ONLY. `req.userId` answers "on whose behalf", never
580
+ // "who pays": the billing principal stays `req.serviceApp
581
+ // .ownerAccountId`, which this branch does not touch. Read it
582
+ // through `getOxyBillingPrincipal` (`@oxyhq/core/server`), whose
583
+ // return type a user id cannot satisfy (ADR 0007).
541
584
  req.userId = oxyUserId;
542
585
  req.user = { id: oxyUserId } as User;
543
586
  req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
@@ -552,6 +595,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
552
595
  appId,
553
596
  appName: decoded.appName || 'unknown',
554
597
  credentialId,
598
+ ownerAccountId,
555
599
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
556
600
  environment,
557
601
  };
@@ -896,7 +940,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
896
940
  * @example
897
941
  * ```typescript
898
942
  * // Protect internal endpoints
899
- * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
943
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
900
944
  *
901
945
  * app.post('/internal/trigger', (req, res) => {
902
946
  * console.log('Service app:', req.serviceApp);
@@ -936,7 +980,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
936
980
  * ```typescript
937
981
  * app.use(
938
982
  * '/internal/files',
939
- * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
983
+ * oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
940
984
  * oxy.requireScope('files:write'),
941
985
  * );
942
986
  * ```