@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
@@ -0,0 +1,95 @@
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
+ import { OxyInferenceClient } from '../inference/OxyInferenceClient';
26
+ import type { OxyServicesBase } from '../OxyServices.base';
27
+ export declare function OxyServicesInferenceMixin<T extends typeof OxyServicesBase>(Base: T): {
28
+ new (...args: any[]): {
29
+ /** @internal Memoized so repeated calls return one object identity. */
30
+ _inferenceClient: OxyInferenceClient | null;
31
+ /**
32
+ * The inference client for this session.
33
+ *
34
+ * Bound to this instance's base URL and to `getAccessToken()`, so it
35
+ * follows every refresh, sign-in and account switch without being
36
+ * rebuilt.
37
+ *
38
+ * A service-authenticated process wants a different credential and
39
+ * builds {@link OxyInferenceClient} directly:
40
+ * `new OxyInferenceClient({ credential: () => oxy.getServiceToken() })`.
41
+ * The mint is asynchronous and cached, which is exactly what a
42
+ * credential function is for.
43
+ */
44
+ inference(): OxyInferenceClient;
45
+ httpService: import("../HttpService").HttpService;
46
+ cloudURL: string;
47
+ config: import("../OxyServices.base").OxyConfig;
48
+ __resetTokensForTests(): void;
49
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
50
+ getBaseURL(): string;
51
+ getClient(): import("../HttpService").HttpService;
52
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
53
+ getMetrics(): {
54
+ totalRequests: number;
55
+ successfulRequests: number;
56
+ failedRequests: number;
57
+ cacheHits: number;
58
+ cacheMisses: number;
59
+ averageResponseTime: number;
60
+ };
61
+ clearCache(): void;
62
+ clearCacheEntry(key: string): void;
63
+ clearCacheByPrefix(prefix: string): number;
64
+ getCacheStats(): {
65
+ size: number;
66
+ hits: number;
67
+ misses: number;
68
+ hitRate: number;
69
+ };
70
+ getCloudURL(): string;
71
+ setTokens(accessToken: string): void;
72
+ clearTokens(): void;
73
+ onTokensChanged(listener: (accessToken: string | null) => void): () => void;
74
+ _cachedUserId: string | null | undefined;
75
+ _cachedAccessToken: string | null;
76
+ getCurrentUserId(): string | null;
77
+ hasValidToken(): boolean;
78
+ getAccessToken(): string | null;
79
+ getAccessTokenExpiry(): number | null;
80
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
81
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
82
+ maxRetries?: number;
83
+ retryDelay?: number;
84
+ authTimeoutMs?: number;
85
+ }): Promise<T_1>;
86
+ validate(): Promise<boolean>;
87
+ handleError(error: unknown): Error;
88
+ healthCheck(): Promise<{
89
+ status: string;
90
+ users?: number;
91
+ timestamp?: string;
92
+ [key: string]: any;
93
+ }>;
94
+ };
95
+ } & T;
@@ -19,9 +19,21 @@ export interface ServiceActingAsVerification {
19
19
  }
20
20
  /**
21
21
  * Service app metadata attached to requests authenticated with service tokens.
22
- * `scopes` reflects the scopes granted to the app at signup time (from the
23
- * `Application.scopes` field); route-level checks can require additional
24
- * scope-narrowing via `requireScope()`.
22
+ *
23
+ * Every field comes from the token's SIGNED payload and is populated only after
24
+ * the signature, `iss`/`aud`/`type` binding and expiry all pass — so a verifier
25
+ * holding this object can name the responsible principals without a lookup of
26
+ * its own. Together with `credentialId` and `ownerAccountId` it is the canonical
27
+ * attribution tuple of ADR 0007 minus the delegated user.
28
+ *
29
+ * `scopes` are the EFFECTIVE scopes: the credential's own scopes intersected
30
+ * with the owning application's grant at mint time (the API's `intersectScopes`
31
+ * is the single authority for that intersection — nothing re-intersects here).
32
+ * Route-level checks narrow further via `requireScope()`.
33
+ *
34
+ * A delegated end user is NOT a field of this type, and must never become one.
35
+ * It lives in `req.serviceActingAs` / `req.userId`, is authorised per request,
36
+ * and is attribution only.
25
37
  */
26
38
  export interface ServiceApp {
27
39
  appId: string;
@@ -29,6 +41,12 @@ export interface ServiceApp {
29
41
  scopes: string[];
30
42
  /** The credentialId of the specific service credential that minted this token. */
31
43
  credentialId: string;
44
+ /**
45
+ * The Oxy account that owns `appId` and is financially responsible for it.
46
+ * The BILLING principal — never a user id, and never the delegated
47
+ * `X-Oxy-User-Id` (ADR 0007).
48
+ */
49
+ ownerAccountId: string;
32
50
  /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
33
51
  environment: OxyServiceEnvironment;
34
52
  }
@@ -49,11 +67,24 @@ interface AuthMiddlewareOptions {
49
67
  * When provided, service tokens will be cryptographically verified.
50
68
  * When omitted, service tokens will be rejected (secure default).
51
69
  *
52
- * **Migration note (>=1.11.14):** the Oxy API now signs service tokens
53
- * with a dedicated `SERVICE_TOKEN_SECRET` distinct from `ACCESS_TOKEN_SECRET`.
54
- * Pass that value here. If you keep passing the access-token secret you will
55
- * still verify ALL signed-by-Oxy tokens (which is the whole class of bug
56
- * H4 was supposed to prevent DO NOT do that in production).
70
+ * **The only value that works is `ACCESS_TOKEN_SECRET`, and you should not
71
+ * want to hold it see issue #987 and ADR 0012.** The Oxy API signs service
72
+ * tokens with `ACCESS_TOKEN_SECRET` (`packages/api/src/routes/auth.ts`),
73
+ * which is also the key that signs every user access token. There is no
74
+ * separate service-token secret: earlier revisions of this comment named a
75
+ * `SERVICE_TOKEN_SECRET` that has never existed in the API, in any workflow or
76
+ * in any task definition, and passing one would fail every verification.
77
+ *
78
+ * The consequence to hold onto: the scheme is symmetric, so a host that can
79
+ * VERIFY a service token can also MINT one — including a user access token.
80
+ * **Local verification is therefore appropriate only inside the Oxy API's own
81
+ * trust boundary, and no service outside it holds this key today.** Do not be
82
+ * the first: if you need to verify Oxy service tokens from another service,
83
+ * follow #987 rather than copying the secret.
84
+ *
85
+ * `docs/adr/0012-service-token-signing-key-model.md` records the decision to
86
+ * retire this option in favour of asymmetric signing against a published
87
+ * JWKS, at which point it is removed rather than deprecated.
57
88
  */
58
89
  jwtSecret?: string;
59
90
  /**
@@ -125,7 +156,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
125
156
  * additionally checked for `aud`, `iss`, and `type` claims to prevent
126
157
  * cross-token-type confusion attacks.
127
158
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
128
- * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
159
+ * direct access to `ACCESS_TOKEN_SECRET`.
129
160
  *
130
161
  * **Why session-less user tokens are refused rather than trusted:**
131
162
  * every user access token the Oxy API issues carries a `sessionId` (see
@@ -157,7 +188,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
157
188
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
158
189
  *
159
190
  * // Protect all routes under /protected
160
- * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
191
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
161
192
  *
162
193
  * // Access user in route handler
163
194
  * app.get('/protected/me', (req, res) => {
@@ -171,7 +202,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
171
202
  * app.use('/public', oxy.auth({ optional: true }));
172
203
  *
173
204
  * // Require a specific scope on a service-token-protected route
174
- * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
205
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
175
206
  * ```
176
207
  *
177
208
  * @param options Optional configuration
@@ -213,7 +244,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
213
244
  * @example
214
245
  * ```typescript
215
246
  * // Protect internal endpoints
216
- * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
247
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
217
248
  *
218
249
  * app.post('/internal/trigger', (req, res) => {
219
250
  * console.log('Service app:', req.serviceApp);
@@ -242,7 +273,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
242
273
  * ```typescript
243
274
  * app.use(
244
275
  * '/internal/files',
245
- * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
276
+ * oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
246
277
  * oxy.requireScope('files:write'),
247
278
  * );
248
279
  * ```
@@ -32,6 +32,7 @@ import { OxyServicesChainsMixin } from './OxyServices.chains';
32
32
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
33
33
  import { OxyServicesLinksMixin } from './OxyServices.links';
34
34
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
35
+ import { OxyServicesInferenceMixin } from './OxyServices.inference';
35
36
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
36
37
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
37
38
  /**
@@ -43,7 +44,7 @@ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
43
44
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
44
45
  * are visible without a cast.
45
46
  */
46
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
47
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
47
48
  /**
48
49
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
49
50
  * a new constructor that augments its input; reducing across the pipeline
@@ -16,6 +16,12 @@ export interface OxyServiceAppContext {
16
16
  appName: string;
17
17
  scopes: string[];
18
18
  credentialId: string;
19
+ /**
20
+ * The Oxy account that owns `appId` and is financially responsible for it.
21
+ * Read off the VERIFIED service-token claim set — never a user id, and never
22
+ * the delegated `X-Oxy-User-Id` (ADR 0007).
23
+ */
24
+ ownerAccountId: string;
19
25
  environment: OxyServiceEnvironment;
20
26
  }
21
27
  export interface OxyServiceActingAsContext {
@@ -44,6 +50,80 @@ export interface OxyAuthMiddlewareOptions {
44
50
  }
45
51
  export declare function getOxyUserId(req: Request): string | null;
46
52
  export declare function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest;
53
+ /**
54
+ * The principal a request is CHARGED to, and the identifiers a receipt needs.
55
+ *
56
+ * Every field is read from the verified service-token claim set. It is an
57
+ * OBJECT, not a string, and that is the point: `getOxyUserId` returns a
58
+ * `string | null`, so a delegated end-user id cannot be passed anywhere an
59
+ * `OxyBillingPrincipal` is expected. The confusion ADR 0007 forbids —
60
+ * attributing spend to the person a service is acting for rather than to the
61
+ * service's own account — stops being a code-review question and becomes a
62
+ * compile error.
63
+ *
64
+ * `scopes` are the effective scopes minted into the token (credential ∩
65
+ * application). Nothing re-intersects them here.
66
+ */
67
+ export interface OxyBillingPrincipal {
68
+ /** `applications.owner_account_id` — the financially responsible account. */
69
+ readonly accountId: string;
70
+ readonly applicationId: string;
71
+ readonly credentialId: string;
72
+ readonly environment: OxyServiceEnvironment;
73
+ readonly scopes: readonly string[];
74
+ }
75
+ /**
76
+ * The full canonical attribution of ADR 0007 for a request: the billing
77
+ * principal PLUS the optional delegated end user.
78
+ *
79
+ * `delegatedUserId` is named for what it is. It answers "on whose behalf" and
80
+ * is absent for a machine credential acting for itself — its absence is normal,
81
+ * and nothing may synthesize one. If removing it would change what any account
82
+ * is charged, the code reading it is wrong.
83
+ */
84
+ export interface OxyRequestAttribution extends OxyBillingPrincipal {
85
+ readonly delegatedUserId: string | null;
86
+ }
87
+ /**
88
+ * The billing principal of a request, or `null` when the request carries no
89
+ * verified service principal (an ordinary user session is not a billable
90
+ * machine principal — its account is resolved from the account graph, not from
91
+ * a token claim).
92
+ *
93
+ * Reads `req.serviceApp` and NOTHING else: not `req.userId`, not `req.user`,
94
+ * not `req.serviceActingAs`. That exclusivity is the invariant this function
95
+ * exists to hold, and `serviceTokenAttribution.test.ts` mutation-tests it.
96
+ *
97
+ * **It answers for the SERVICE-TOKEN lane only.** The API's machine-credential
98
+ * lane (`oxy_sk_*`, issue #972 §2.3) resolves the same five facts into its own
99
+ * `req.machineCredential`, deliberately never `req.serviceApp` — populating the
100
+ * latter would hand a self-serve third-party credential the lane that only
101
+ * platform-trusted applications may enter. So a machine-credential request has
102
+ * no billing principal HERE and resolves `null`, which fails closed: the caller
103
+ * must handle it, and `getRequiredOxyBillingPrincipal` throws rather than
104
+ * charging anyone. One accessor answering for both lanes belongs to the public
105
+ * inference edge that has to admit both, and it needs the machine principal's
106
+ * shape to move into this package first.
107
+ */
108
+ export declare function getOxyBillingPrincipal(req: Request): OxyBillingPrincipal | null;
109
+ /**
110
+ * {@link getOxyBillingPrincipal}, throwing when the request has none. Use on
111
+ * routes that have already required a service token.
112
+ */
113
+ export declare function getRequiredOxyBillingPrincipal(req: Request): OxyBillingPrincipal;
114
+ /**
115
+ * The delegated end user of a service request, or `null`.
116
+ *
117
+ * Deliberately reads `req.serviceActingAs` — the grant-verified delegation —
118
+ * and not `req.userId`, which on a non-service request is the caller's own
119
+ * session identity and is not a delegation at all.
120
+ */
121
+ export declare function getOxyDelegatedUserId(req: Request): string | null;
122
+ /**
123
+ * The whole attribution tuple for a service request: who pays, which
124
+ * application and credential, and optionally on whose behalf.
125
+ */
126
+ export declare function getOxyRequestAttribution(req: Request): OxyRequestAttribution | null;
47
127
  export declare function getRequiredOxyUserId(req: Request): string;
48
128
  export declare function requireOxyAuth(req: Request, res: Response, next: NextFunction): void;
49
129
  export declare function createOptionalOxyAuth(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
@@ -15,6 +15,7 @@
15
15
  * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
16
16
  * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
17
17
  * - allows the caller's explicit `appOrigins`,
18
+ * - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
18
19
  * - DENIES everything else (no reflection, never a wildcard with credentials),
19
20
  * - echoes back the EXACT matched origin (so credentialed requests work) and
20
21
  * sets `Vary: Origin` for correct caching,
@@ -29,6 +30,11 @@ export interface OxyCorsOptions {
29
30
  * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
30
31
  * ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
31
32
  * via `new URL().origin`.
33
+ *
34
+ * An entry that is not a URL, or whose origin is the opaque origin
35
+ * (`exp://…`, `capacitor://…`, `chrome-extension://…`, `file:`, `data:`), is
36
+ * DROPPED with an error log rather than admitted — see `OPAQUE_ORIGIN` for
37
+ * why one such entry would otherwise admit every other one.
32
38
  */
33
39
  appOrigins?: string[];
34
40
  /**
@@ -46,6 +52,41 @@ export interface OxyCorsOptions {
46
52
  /** Preflight cache lifetime in seconds. Default 86400 (24h). */
47
53
  maxAgeSeconds?: number;
48
54
  }
55
+ /**
56
+ * Normalize the configured `appOrigins` into the exact-match set — the
57
+ * CONFIGURE-SIDE half of the opaque-origin guard.
58
+ *
59
+ * An entry that is not a URL, or whose origin is opaque, is dropped and named
60
+ * in an error log. Dropped rather than thrown on because `appOrigins` is
61
+ * deployment configuration — at least one Oxy backend reads it from the
62
+ * environment — and a typo there must cost that one origin its CORS headers,
63
+ * never the whole service its boot. Both failure modes are equally SAFE (the
64
+ * entry is absent from the set either way), so the choice is purely about
65
+ * blast radius, and dropping keeps it to one origin whose requests then fail
66
+ * visibly in the browser.
67
+ *
68
+ * Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
69
+ * `server/index.ts`, so it is not part of the package's public surface. The
70
+ * two halves of the guard are separately exported because they are separately
71
+ * testable only that way: with this half in place the match-side half is
72
+ * unreachable through `createOxyCors`, so a test driving the public API alone
73
+ * would measure this function twice and the other one never.
74
+ */
75
+ export declare function normalizeAppOrigins(appOrigins: string[]): Set<string>;
76
+ /**
77
+ * Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
78
+ * family, or it exactly matches one of the configured app origins.
79
+ *
80
+ * The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
81
+ * is what makes the property hold regardless of how `explicit` was built — a
82
+ * set that somehow contains `"null"` still matches nothing, because no
83
+ * incoming origin ever normalizes past this line. `normalizeAppOrigins` is
84
+ * what stops such a set existing today; this is what stops it mattering.
85
+ *
86
+ * Exported for the same reason as `normalizeAppOrigins`, and likewise absent
87
+ * from `server/index.ts`.
88
+ */
89
+ export declare function matchesAllowedOrigin(explicit: ReadonlySet<string>, origin: string): boolean;
49
90
  /**
50
91
  * Create a strict Oxy CORS middleware. See module docs.
51
92
  *
@@ -14,8 +14,8 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
- export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
+ export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
21
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "21.0.0",
3
+ "version": "21.0.2",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -116,7 +116,7 @@
116
116
  "dependencies": {
117
117
  "@noble/ciphers": "^1.3.0",
118
118
  "@noble/hashes": "^1.8.0",
119
- "@oxyhq/contracts": "^0.26.0",
119
+ "@oxyhq/contracts": "^0.30.0",
120
120
  "@oxyhq/protocol": "^0.2.0",
121
121
  "@scure/bip39": "^1.6.0",
122
122
  "@types/elliptic": "^6.4.18",
@@ -913,6 +913,19 @@ export class HttpService {
913
913
  */
914
914
  private static readonly CACHE_IDENTITY_DELIM = ' id=';
915
915
 
916
+ /**
917
+ * The keys whose presence beside `data` makes a body a PAGE rather than a
918
+ * payload — see {@link unwrapResponse} for why this list is narrow.
919
+ *
920
+ * - `pagination` — the offset-paginated house envelope (`sendPaginated`).
921
+ * - `nextCursor` — the keyset-paginated one (the account audit trails).
922
+ *
923
+ * Membership is decided by key PRESENCE, never by value: the last page sends
924
+ * `nextCursor: null`, and an envelope that collapsed into a bare payload
925
+ * exactly when the stream ended would be a worse bug than the one this fixes.
926
+ */
927
+ private static readonly PAGE_ENVELOPE_KEYS: readonly string[] = ['pagination', 'nextCursor'];
928
+
916
929
  /**
917
930
  * Derive a stable, non-sensitive identity discriminator for cache scoping.
918
931
  *
@@ -1212,21 +1225,48 @@ export class HttpService {
1212
1225
  }
1213
1226
 
1214
1227
  /**
1215
- * Unwrap standardized API response format
1228
+ * Unwrap the standardized API response envelope — EXCEPT when the envelope is
1229
+ * a page, in which case it travels whole.
1230
+ *
1231
+ * `{ data: <payload> }` is the house success envelope (`sendSuccess`), and
1232
+ * reducing it to `<payload>` is what every call site in the SDK expects. But
1233
+ * the reduction DISCARDS every sibling key, silently, and a page's siblings
1234
+ * are the only thing that says where the next page starts. That is how
1235
+ * `GET /accounts/:id/audit` lost its `nextCursor`: the caller received a bare
1236
+ * array, `getNextPageParam` read `undefined`, and pagination was dead past the
1237
+ * first page with nothing to show that it was.
1238
+ *
1239
+ * ## Why the rule is narrow, and not "any sibling key survives"
1240
+ *
1241
+ * "An object carrying `data` plus anything else is not an envelope" is the
1242
+ * tempting general rule, and it is wrong here: this API already answers
1243
+ * `{ data, count }` on ~15 routes, plus `{ data, source }`, `{ data, reason }`
1244
+ * and `{ data, secretDestroyed }`, and a dozen measured Console call sites
1245
+ * type those as the bare payload (`Array<ProviderConnection>`,
1246
+ * `AccountBillingState | null`, …). Preserving those envelopes would hand every
1247
+ * one of them an object where it expects its payload — at runtime only, since
1248
+ * the response type is a call-site assertion. So the rule names PAGINATION
1249
+ * specifically: `data` beside {@link PAGE_ENVELOPE_KEYS} is a page.
1250
+ *
1251
+ * A route whose sibling key genuinely matters to its caller belongs in that
1252
+ * list, or should not be a sibling of `data` at all — the cursor-paginated
1253
+ * surfaces already in the SDK (`{ follows, nextCursor }`,
1254
+ * `{ records, nextCursor }`) sidestep this by never using `data`.
1216
1255
  */
1217
1256
  private unwrapResponse(responseData: unknown): unknown {
1218
- // Handle paginated responses: { data: [...], pagination: {...} }
1219
- if (responseData && typeof responseData === 'object' && 'data' in responseData && 'pagination' in responseData) {
1257
+ if (!responseData || typeof responseData !== 'object' || !('data' in responseData)) {
1258
+ // Not the success envelope (or not an object at all) as-is.
1220
1259
  return responseData;
1221
1260
  }
1222
-
1223
- // Handle regular success responses: { data: ... }
1224
- if (responseData && typeof responseData === 'object' && 'data' in responseData && !Array.isArray(responseData)) {
1225
- return responseData.data;
1261
+
1262
+ // A page travels whole: its cursor/pagination sibling is unrecoverable
1263
+ // information, not decoration.
1264
+ if (HttpService.PAGE_ENVELOPE_KEYS.some((key) => key in responseData)) {
1265
+ return responseData;
1226
1266
  }
1227
-
1228
- // Return as-is for responses that don't use sendSuccess wrapper
1229
- return responseData;
1267
+
1268
+ // Regular success envelope: `{ data: ... }` -> the payload.
1269
+ return Array.isArray(responseData) ? responseData : responseData.data;
1230
1270
  }
1231
1271
 
1232
1272
  /**
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `HttpService.unwrapResponse` envelope tests — driven through the REAL class
3
+ * against a stubbed `fetch`, never by calling the private method.
4
+ *
5
+ * The convenience unwrap reduces the house `{ data: <payload> }` envelope to its
6
+ * payload, and in doing so it discards every sibling key. That silently killed
7
+ * pagination on the account audit trails (`GET /accounts/:id/audit` and
8
+ * `GET /accounts/:id/billing/audit`, which answer `{ data, count, nextCursor }`):
9
+ * the caller got a bare array, `getNextPageParam` read `undefined`, and there was
10
+ * nothing at the call site to show that page 2 could never be requested.
11
+ *
12
+ * The three cases below are the whole contract, and each is load-bearing:
13
+ *
14
+ * - a cursor page travels WHOLE (the regression),
15
+ * - a `{ data, pagination }` page still travels whole (the behaviour that
16
+ * already existed — the positive control for "pages are preserved"),
17
+ * - a bare `{ data }` body still unwraps (the control proving the convenience
18
+ * every other call site depends on is intact).
19
+ *
20
+ * Reverting the fix must redden the first and leave the other two green.
21
+ */
22
+
23
+ import { HttpService } from '../HttpService';
24
+
25
+ /** One entry of an audit page — only the shape matters here. */
26
+ const ENTRY = { source: 'application_credential', eventType: 'created' } as const;
27
+
28
+ function jsonBody(body: unknown): Response {
29
+ return new Response(JSON.stringify(body), {
30
+ status: 200,
31
+ headers: { 'content-type': 'application/json' },
32
+ });
33
+ }
34
+
35
+ describe('HttpService response-envelope unwrapping', () => {
36
+ const originalFetch = globalThis.fetch;
37
+
38
+ afterEach(() => {
39
+ globalThis.fetch = originalFetch;
40
+ });
41
+
42
+ /** Drive a real `HttpService.get` against a stubbed wire body. */
43
+ async function fetchWireBody<T>(body: unknown): Promise<T> {
44
+ globalThis.fetch = jest.fn(async () => jsonBody(body)) as unknown as typeof fetch;
45
+ const http = new HttpService({ baseURL: 'http://api.test.invalid' });
46
+ return http.get<T>('/accounts/acct-1/audit', { cache: false });
47
+ }
48
+
49
+ it('preserves a cursor page whole — `count` and `nextCursor` reach the caller', async () => {
50
+ const wire = { data: [ENTRY], count: 1, nextCursor: 'CURSOR-XYZ' };
51
+
52
+ const page = await fetchWireBody<typeof wire>(wire);
53
+
54
+ expect(page).toEqual(wire);
55
+ // The cursor is the only thing that says where page 2 starts. Asserted on
56
+ // its own because `toEqual` above would also pass a body that merely
57
+ // happened to be an array of one entry.
58
+ expect(page.nextCursor).toBe('CURSOR-XYZ');
59
+ expect(page.count).toBe(1);
60
+ expect(page.data).toEqual([ENTRY]);
61
+ });
62
+
63
+ it('preserves the LAST cursor page, where `nextCursor` is null', async () => {
64
+ // The recognition is by key presence, not truthiness: if the envelope
65
+ // collapsed into a bare array exactly when the stream ended, every caller
66
+ // would crash on the final page instead of finishing.
67
+ const wire = { data: [ENTRY], count: 1, nextCursor: null };
68
+
69
+ const page = await fetchWireBody<typeof wire>(wire);
70
+
71
+ expect(page).toEqual(wire);
72
+ expect(page.nextCursor).toBeNull();
73
+ });
74
+
75
+ it('preserves the offset-paginated `{ data, pagination }` envelope (control)', async () => {
76
+ const wire = {
77
+ data: [ENTRY],
78
+ pagination: { total: 1, limit: 50, offset: 0, hasMore: false },
79
+ };
80
+
81
+ const page = await fetchWireBody<typeof wire>(wire);
82
+
83
+ expect(page).toEqual(wire);
84
+ });
85
+
86
+ it('still unwraps a bare `{ data }` success envelope to its payload (control)', async () => {
87
+ const payload = { id: 'acct-1', name: 'Acme' };
88
+
89
+ const unwrapped = await fetchWireBody<typeof payload>({ data: payload });
90
+
91
+ expect(unwrapped).toEqual(payload);
92
+ });
93
+
94
+ it('still unwraps `{ data, count }`, which a dozen callers type as the bare payload', async () => {
95
+ // `{ data, count }` is answered by ~15 routes whose Console call sites type
96
+ // the result `Array<T>`. `count` is `data.length` — recoverable — so this
97
+ // envelope deliberately does NOT survive, and widening the rule to "any
98
+ // sibling key" would break every one of those callers at runtime.
99
+ const entries = [ENTRY, ENTRY];
100
+
101
+ const unwrapped = await fetchWireBody<typeof entries>({ data: entries, count: 2 });
102
+
103
+ expect(unwrapped).toEqual(entries);
104
+ });
105
+
106
+ it('passes through a body that has no `data` key at all', async () => {
107
+ // The cursor surfaces already in the SDK (`{ follows, nextCursor }`,
108
+ // `{ records, nextCursor }`) rely on this lane.
109
+ const wire = { follows: [{ relationshipId: 'rel-1' }], nextCursor: 'CURSOR-ABC' };
110
+
111
+ const passed = await fetchWireBody<typeof wire>(wire);
112
+
113
+ expect(passed).toEqual(wire);
114
+ });
115
+ });
@@ -2136,8 +2136,13 @@
2136
2136
  "filesWrite": "Upload and modify your files",
2137
2137
  "filesDelete": "Delete your files",
2138
2138
  "webhooksReceive": "Receive webhooks",
2139
- "chatCompletions": "Use AI chat on your behalf",
2140
- "modelsRead": "List available AI models",
2139
+ "inferenceInvoke": "Run AI requests on your behalf",
2140
+ "inferenceModelsRead": "List available AI models",
2141
+ "inferenceUsageRead": "Read its AI usage and costs",
2142
+ "inferenceRoutingRead": "Read how AI requests are routed",
2143
+ "inferenceRoutingWrite": "Change how AI requests are routed",
2144
+ "inferenceProvidersRead": "Read its connected AI providers",
2145
+ "inferenceProvidersWrite": "Manage its connected AI providers",
2141
2146
  "federationWrite": "Act across federated services"
2142
2147
  },
2143
2148
  "account": {
@@ -2136,8 +2136,13 @@
2136
2136
  "filesWrite": "Subir y modificar tus archivos",
2137
2137
  "filesDelete": "Eliminar tus archivos",
2138
2138
  "webhooksReceive": "Recibir webhooks",
2139
- "chatCompletions": "Usar el chat con IA en tu nombre",
2140
- "modelsRead": "Ver los modelos de IA disponibles",
2139
+ "inferenceInvoke": "Ejecutar peticiones de IA en tu nombre",
2140
+ "inferenceModelsRead": "Ver los modelos de IA disponibles",
2141
+ "inferenceUsageRead": "Ver su consumo y costes de IA",
2142
+ "inferenceRoutingRead": "Ver cómo se enrutan las peticiones de IA",
2143
+ "inferenceRoutingWrite": "Cambiar cómo se enrutan las peticiones de IA",
2144
+ "inferenceProvidersRead": "Ver sus proveedores de IA conectados",
2145
+ "inferenceProvidersWrite": "Gestionar sus proveedores de IA conectados",
2141
2146
  "federationWrite": "Actuar en servicios federados"
2142
2147
  },
2143
2148
  "account": {