@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
@@ -149,7 +149,7 @@ export function OxyServicesUtilityMixin(Base) {
149
149
  * additionally checked for `aud`, `iss`, and `type` claims to prevent
150
150
  * cross-token-type confusion attacks.
151
151
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
152
- * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
152
+ * direct access to `ACCESS_TOKEN_SECRET`.
153
153
  *
154
154
  * **Why session-less user tokens are refused rather than trusted:**
155
155
  * every user access token the Oxy API issues carries a `sessionId` (see
@@ -181,7 +181,7 @@ export function OxyServicesUtilityMixin(Base) {
181
181
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
182
182
  *
183
183
  * // Protect all routes under /protected
184
- * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
184
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
185
185
  *
186
186
  * // Access user in route handler
187
187
  * app.get('/protected/me', (req, res) => {
@@ -195,7 +195,7 @@ export function OxyServicesUtilityMixin(Base) {
195
195
  * app.use('/public', oxy.auth({ optional: true }));
196
196
  *
197
197
  * // Require a specific scope on a service-token-protected route
198
- * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
198
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
199
199
  * ```
200
200
  *
201
201
  * @param options Optional configuration
@@ -360,13 +360,19 @@ export function OxyServicesUtilityMixin(Base) {
360
360
  return onError(error);
361
361
  return res.status(401).json(error);
362
362
  }
363
- // Validate required service token fields
363
+ // Validate required service token fields. All of them are
364
+ // required, `ownerAccountId` included: an optional billing
365
+ // principal is one fallback away from being resolved from the
366
+ // delegated user, which is the exact confusion ADR 0007 forbids.
364
367
  const appId = decoded.appId;
365
368
  const credentialId = decoded.credentialId;
369
+ const ownerAccountId = decoded.ownerAccountId;
366
370
  const environment = decoded.environment;
367
371
  if (!appId ||
368
372
  typeof credentialId !== 'string' ||
369
373
  credentialId.length === 0 ||
374
+ typeof ownerAccountId !== 'string' ||
375
+ ownerAccountId.length === 0 ||
370
376
  !isOxyServiceEnvironment(environment)) {
371
377
  if (optional) {
372
378
  req.userId = null;
@@ -405,6 +411,11 @@ export function OxyServicesUtilityMixin(Base) {
405
411
  return onError(error);
406
412
  return res.status(403).json(error);
407
413
  }
414
+ // ATTRIBUTION ONLY. `req.userId` answers "on whose behalf", never
415
+ // "who pays": the billing principal stays `req.serviceApp
416
+ // .ownerAccountId`, which this branch does not touch. Read it
417
+ // through `getOxyBillingPrincipal` (`@oxyhq/core/server`), whose
418
+ // return type a user id cannot satisfy (ADR 0007).
408
419
  req.userId = oxyUserId;
409
420
  req.user = { id: oxyUserId };
410
421
  req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
@@ -419,6 +430,7 @@ export function OxyServicesUtilityMixin(Base) {
419
430
  appId,
420
431
  appName: decoded.appName || 'unknown',
421
432
  credentialId,
433
+ ownerAccountId,
422
434
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
423
435
  environment,
424
436
  };
@@ -735,7 +747,7 @@ export function OxyServicesUtilityMixin(Base) {
735
747
  * @example
736
748
  * ```typescript
737
749
  * // Protect internal endpoints
738
- * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
750
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
739
751
  *
740
752
  * app.post('/internal/trigger', (req, res) => {
741
753
  * console.log('Service app:', req.serviceApp);
@@ -773,7 +785,7 @@ export function OxyServicesUtilityMixin(Base) {
773
785
  * ```typescript
774
786
  * app.use(
775
787
  * '/internal/files',
776
- * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
788
+ * oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
777
789
  * oxy.requireScope('files:write'),
778
790
  * );
779
791
  * ```
@@ -32,6 +32,7 @@ import { OxyServicesChainsMixin } from './OxyServices.chains.js';
32
32
  import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
33
33
  import { OxyServicesLinksMixin } from './OxyServices.links.js';
34
34
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph.js';
35
+ import { OxyServicesInferenceMixin } from './OxyServices.inference.js';
35
36
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
36
37
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer.js';
37
38
  /**
@@ -96,6 +97,11 @@ const MIXIN_PIPELINE = [
96
97
  // The user-owned follow graph (#809). One relationship per user and target,
97
98
  // shared across applications, with per-application context on top.
98
99
  OxyServicesFollowGraphMixin,
100
+ // The inference model catalogue (#972). Reads only, and deliberately no
101
+ // request/stream/receipt methods — the public inference edge those would
102
+ // call is workstream 4 and does not exist yet. See
103
+ // `docs/inference/README.md` for what is and is not built.
104
+ OxyServicesInferenceMixin,
99
105
  // Device-first token mint: the client half of the zero-cookie transport
100
106
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
101
107
  OxyServicesDeviceBootMixin,
@@ -27,6 +27,78 @@ export function getOxyUserId(req) {
27
27
  export function isOxyAuthenticated(req) {
28
28
  return getOxyUserId(req) !== null;
29
29
  }
30
+ /**
31
+ * The billing principal of a request, or `null` when the request carries no
32
+ * verified service principal (an ordinary user session is not a billable
33
+ * machine principal — its account is resolved from the account graph, not from
34
+ * a token claim).
35
+ *
36
+ * Reads `req.serviceApp` and NOTHING else: not `req.userId`, not `req.user`,
37
+ * not `req.serviceActingAs`. That exclusivity is the invariant this function
38
+ * exists to hold, and `serviceTokenAttribution.test.ts` mutation-tests it.
39
+ *
40
+ * **It answers for the SERVICE-TOKEN lane only.** The API's machine-credential
41
+ * lane (`oxy_sk_*`, issue #972 §2.3) resolves the same five facts into its own
42
+ * `req.machineCredential`, deliberately never `req.serviceApp` — populating the
43
+ * latter would hand a self-serve third-party credential the lane that only
44
+ * platform-trusted applications may enter. So a machine-credential request has
45
+ * no billing principal HERE and resolves `null`, which fails closed: the caller
46
+ * must handle it, and `getRequiredOxyBillingPrincipal` throws rather than
47
+ * charging anyone. One accessor answering for both lanes belongs to the public
48
+ * inference edge that has to admit both, and it needs the machine principal's
49
+ * shape to move into this package first.
50
+ */
51
+ export function getOxyBillingPrincipal(req) {
52
+ const serviceApp = req.serviceApp;
53
+ if (!serviceApp) {
54
+ return null;
55
+ }
56
+ const accountId = normalizeId(serviceApp.ownerAccountId);
57
+ const applicationId = normalizeId(serviceApp.appId);
58
+ const credentialId = normalizeId(serviceApp.credentialId);
59
+ if (!accountId || !applicationId || !credentialId) {
60
+ return null;
61
+ }
62
+ return {
63
+ accountId,
64
+ applicationId,
65
+ credentialId,
66
+ environment: serviceApp.environment,
67
+ scopes: serviceApp.scopes,
68
+ };
69
+ }
70
+ /**
71
+ * {@link getOxyBillingPrincipal}, throwing when the request has none. Use on
72
+ * routes that have already required a service token.
73
+ */
74
+ export function getRequiredOxyBillingPrincipal(req) {
75
+ const principal = getOxyBillingPrincipal(req);
76
+ if (!principal) {
77
+ throw new Error('Request has no verified Oxy service principal');
78
+ }
79
+ return principal;
80
+ }
81
+ /**
82
+ * The delegated end user of a service request, or `null`.
83
+ *
84
+ * Deliberately reads `req.serviceActingAs` — the grant-verified delegation —
85
+ * and not `req.userId`, which on a non-service request is the caller's own
86
+ * session identity and is not a delegation at all.
87
+ */
88
+ export function getOxyDelegatedUserId(req) {
89
+ return normalizeId(req.serviceActingAs?.userId);
90
+ }
91
+ /**
92
+ * The whole attribution tuple for a service request: who pays, which
93
+ * application and credential, and optionally on whose behalf.
94
+ */
95
+ export function getOxyRequestAttribution(req) {
96
+ const principal = getOxyBillingPrincipal(req);
97
+ if (!principal) {
98
+ return null;
99
+ }
100
+ return { ...principal, delegatedUserId: getOxyDelegatedUserId(req) };
101
+ }
30
102
  export function getRequiredOxyUserId(req) {
31
103
  const userId = getOxyUserId(req);
32
104
  if (!userId) {
@@ -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,
@@ -22,7 +23,9 @@
22
23
  *
23
24
  * Node/Express-only: exported solely from `@oxyhq/core/server`.
24
25
  */
26
+ import { createLogger } from '../logger/index.js';
25
27
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
28
+ const log = createLogger('OxyCors');
26
29
  /** Default HTTP methods allowed across origins. */
27
30
  const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
28
31
  /** Default request headers a browser may send on a credentialed cross-origin call. */
@@ -61,6 +64,29 @@ function isOxyFamilyOrigin(candidate) {
61
64
  return false;
62
65
  }
63
66
  }
67
+ /**
68
+ * The URL standard's serialization of an OPAQUE origin: the literal string
69
+ * `"null"`, which `new URL(x).origin` returns for every scheme that has no
70
+ * origin to speak of — `exp:`, `capacitor:`, `chrome-extension:`,
71
+ * `vscode-webview:`, and also `file:`, `data:` and `about:`.
72
+ *
73
+ * This value is why an allowlist may never store it. Every such scheme
74
+ * normalizes to the SAME `"null"`, so a set built by normalization cannot tell
75
+ * them apart: ONE opaque entry admits ALL of them. With credentials on and the
76
+ * raw header echoed back, a single `myapp://` in `appOrigins` turned this
77
+ * helper into "allow any custom-scheme browsing context" — measured live, an
78
+ * `exp://localhost:8150` entry answered `Origin: vscode-webview://…` with
79
+ * `access-control-allow-origin: vscode-webview://…` and
80
+ * `access-control-allow-credentials: true`.
81
+ *
82
+ * There is deliberately no escape hatch that matches such an origin by raw
83
+ * string instead. Admitting a custom-scheme browsing context to a CREDENTIALED
84
+ * allowlist is a distinct decision with its own threat model, and it must not
85
+ * arrive as a side effect of someone adding one line to `appOrigins`. Note
86
+ * also that a native client is not subject to CORS at all — React Native sends
87
+ * no `Origin` header — so a mobile app never needs an entry here.
88
+ */
89
+ const OPAQUE_ORIGIN = 'null';
64
90
  /** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
65
91
  function normalizeOrigin(raw) {
66
92
  try {
@@ -71,24 +97,65 @@ function normalizeOrigin(raw) {
71
97
  }
72
98
  }
73
99
  /**
74
- * Build the origin-matching predicate: true iff `origin` is in the built-in
75
- * HTTPS Oxy apex family OR exactly matches one of the configured app origins.
100
+ * Normalize the configured `appOrigins` into the exact-match set the
101
+ * CONFIGURE-SIDE half of the opaque-origin guard.
102
+ *
103
+ * An entry that is not a URL, or whose origin is opaque, is dropped and named
104
+ * in an error log. Dropped rather than thrown on because `appOrigins` is
105
+ * deployment configuration — at least one Oxy backend reads it from the
106
+ * environment — and a typo there must cost that one origin its CORS headers,
107
+ * never the whole service its boot. Both failure modes are equally SAFE (the
108
+ * entry is absent from the set either way), so the choice is purely about
109
+ * blast radius, and dropping keeps it to one origin whose requests then fail
110
+ * visibly in the browser.
111
+ *
112
+ * Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
113
+ * `server/index.ts`, so it is not part of the package's public surface. The
114
+ * two halves of the guard are separately exported because they are separately
115
+ * testable only that way: with this half in place the match-side half is
116
+ * unreachable through `createOxyCors`, so a test driving the public API alone
117
+ * would measure this function twice and the other one never.
76
118
  */
77
- function buildOriginAllowed(appOrigins) {
119
+ export function normalizeAppOrigins(appOrigins) {
78
120
  const explicit = new Set();
79
121
  for (const raw of appOrigins) {
80
122
  const normalized = normalizeOrigin(raw);
81
- if (normalized)
82
- explicit.add(normalized);
123
+ if (normalized === null) {
124
+ log.error('CORS allowlist entry ignored: it is not a URL', undefined, { entry: raw });
125
+ continue;
126
+ }
127
+ if (normalized === OPAQUE_ORIGIN) {
128
+ log.error('CORS allowlist entry ignored: it has no origin to match against', undefined, {
129
+ entry: raw,
130
+ });
131
+ continue;
132
+ }
133
+ explicit.add(normalized);
83
134
  }
84
- return (origin) => {
85
- const normalized = normalizeOrigin(origin);
86
- if (normalized === null)
87
- return false;
88
- if (explicit.has(normalized))
89
- return true;
90
- return isOxyFamilyOrigin(normalized);
91
- };
135
+ return explicit;
136
+ }
137
+ /**
138
+ * Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
139
+ * family, or it exactly matches one of the configured app origins.
140
+ *
141
+ * The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
142
+ * is what makes the property hold regardless of how `explicit` was built — a
143
+ * set that somehow contains `"null"` still matches nothing, because no
144
+ * incoming origin ever normalizes past this line. `normalizeAppOrigins` is
145
+ * what stops such a set existing today; this is what stops it mattering.
146
+ *
147
+ * Exported for the same reason as `normalizeAppOrigins`, and likewise absent
148
+ * from `server/index.ts`.
149
+ */
150
+ export function matchesAllowedOrigin(explicit, origin) {
151
+ const normalized = normalizeOrigin(origin);
152
+ if (normalized === null)
153
+ return false;
154
+ if (normalized === OPAQUE_ORIGIN)
155
+ return false;
156
+ if (explicit.has(normalized))
157
+ return true;
158
+ return isOxyFamilyOrigin(normalized);
92
159
  }
93
160
  /**
94
161
  * Create a strict Oxy CORS middleware. See module docs.
@@ -100,7 +167,7 @@ function buildOriginAllowed(appOrigins) {
100
167
  */
101
168
  export function createOxyCors(options = {}) {
102
169
  const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
103
- const isOriginAllowed = buildOriginAllowed(appOrigins);
170
+ const explicitOrigins = normalizeAppOrigins(appOrigins);
104
171
  const methodsHeader = methods.join(', ');
105
172
  const allowedHeadersHeader = allowedHeaders.join(', ');
106
173
  const exposedHeadersHeader = exposedHeaders.join(', ');
@@ -118,7 +185,7 @@ export function createOxyCors(options = {}) {
118
185
  }
119
186
  // Origin is present. Caching correctness: this response varies by Origin.
120
187
  res.setHeader('Vary', 'Origin');
121
- if (!isOriginAllowed(origin)) {
188
+ if (!matchesAllowedOrigin(explicitOrigins, origin)) {
122
189
  // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
123
190
  // will block the cross-origin read. Preflights for denied origins get a
124
191
  // 204 with no CORS headers (the actual request then fails CORS).
@@ -14,7 +14,7 @@
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.js';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
18
18
  export { createOxyRateLimit } from './rateLimit.js';
19
19
  // SSRF-safe upstream fetch + URL validation (Node-only).
20
20
  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.js';