@oxy.so/core 7.16.0 → 7.18.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 (62) hide show
  1. package/README.md +24 -13
  2. package/dist/cjs/server/OxyServer.js +85 -3
  3. package/dist/cjs/server/appPermissions.js +14 -92
  4. package/dist/cjs/server/callerAccess.js +170 -0
  5. package/dist/cjs/server/callerAuthority.js +206 -0
  6. package/dist/cjs/server/capabilityTicket.js +39 -6
  7. package/dist/cjs/server/cors.js +41 -2
  8. package/dist/cjs/server/index.js +33 -5
  9. package/dist/cjs/server/liveCapabilityTicket.js +4 -2
  10. package/dist/cjs/server/middleware.js +104 -2
  11. package/dist/cjs/server/namespaces.js +15 -4
  12. package/dist/cjs/server/safeFetch.js +29 -1
  13. package/dist/cjs/server/userClient.js +127 -0
  14. package/dist/cjs/utils/cache.js +58 -22
  15. package/dist/esm/server/OxyServer.js +85 -3
  16. package/dist/esm/server/appPermissions.js +12 -89
  17. package/dist/esm/server/callerAccess.js +164 -0
  18. package/dist/esm/server/callerAuthority.js +194 -0
  19. package/dist/esm/server/capabilityTicket.js +38 -6
  20. package/dist/esm/server/cors.js +39 -2
  21. package/dist/esm/server/index.js +26 -3
  22. package/dist/esm/server/liveCapabilityTicket.js +5 -3
  23. package/dist/esm/server/middleware.js +104 -2
  24. package/dist/esm/server/namespaces.js +15 -4
  25. package/dist/esm/server/safeFetch.js +29 -1
  26. package/dist/esm/server/userClient.js +121 -0
  27. package/dist/esm/utils/cache.js +57 -22
  28. package/dist/types/api/accounts.d.ts +8 -1
  29. package/dist/types/api/apps.d.ts +22 -4
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/server/OxyServer.d.ts +74 -7
  32. package/dist/types/server/appPermissions.d.ts +3 -13
  33. package/dist/types/server/auth.d.ts +5 -0
  34. package/dist/types/server/callerAccess.d.ts +99 -0
  35. package/dist/types/server/callerAuthority.d.ts +127 -0
  36. package/dist/types/server/capabilityTicket.d.ts +23 -1
  37. package/dist/types/server/cors.d.ts +39 -0
  38. package/dist/types/server/index.d.ts +31 -7
  39. package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
  40. package/dist/types/server/middleware.d.ts +34 -2
  41. package/dist/types/server/namespaces.d.ts +18 -2
  42. package/dist/types/server/safeFetch.d.ts +10 -0
  43. package/dist/types/server/userClient.d.ts +71 -0
  44. package/dist/types/utils/cache.d.ts +13 -0
  45. package/package.json +1 -1
  46. package/src/api/accounts.ts +8 -1
  47. package/src/api/apps.ts +23 -4
  48. package/src/index.ts +1 -0
  49. package/src/server/OxyServer.ts +126 -7
  50. package/src/server/appPermissions.ts +28 -105
  51. package/src/server/auth.ts +5 -0
  52. package/src/server/callerAccess.ts +264 -0
  53. package/src/server/callerAuthority.ts +267 -0
  54. package/src/server/capabilityTicket.ts +76 -17
  55. package/src/server/cors.ts +63 -2
  56. package/src/server/index.ts +38 -4
  57. package/src/server/liveCapabilityTicket.ts +12 -5
  58. package/src/server/middleware.ts +171 -4
  59. package/src/server/namespaces.ts +36 -5
  60. package/src/server/safeFetch.ts +31 -1
  61. package/src/server/userClient.ts +147 -0
  62. package/src/utils/cache.ts +56 -18
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ /**
3
+ * What every "may the person behind this request …?" read on `OxyServer`
4
+ * shares: which requests carry a person at all, the one failure type and its
5
+ * status mapping, the response envelope, and the positive-only answer cache.
6
+ *
7
+ * The readers built on it answer different questions — keep them apart:
8
+ *
9
+ * | Question | Reader |
10
+ * |---|---|
11
+ * | Does this person hold one of MY application's declared operator keys (`payments.operate`)? | `appPermissionsFor` (`./appPermissions`) |
12
+ * | What may this person do in Oxy account X (an organization, store, channel)? | `accountAccessFor` (`./callerAccess`) |
13
+ * | What may this person do to application A, through the account that owns it (`app:read`, `app:update`)? | `applicationAccessFor` (`./callerAccess`) |
14
+ *
15
+ * ## The outcome mapping, and why an outage is never a "no"
16
+ *
17
+ * Every reader asks Oxy with the request's OWN verified bearer and maps the
18
+ * answer the same way: a 401 means the session is gone (sign in again); a 403
19
+ * or 404 is Oxy's definitive refusal; anything else — a 5xx, a timeout, a
20
+ * malformed body — is UNAVAILABLE, answered 503. Only a positive answer is
21
+ * cached, so a blip can never be remembered as a refusal, and a refusal is
22
+ * never remembered at all (the next request asks again).
23
+ *
24
+ * Node only; reachable solely from `@oxy.so/core/server`.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.CallerAuthorityCache = exports.OxyAccessError = void 0;
28
+ exports.readCallerSession = readCallerSession;
29
+ exports.isBearerToken = isBearerToken;
30
+ exports.accessErrorStatus = accessErrorStatus;
31
+ exports.authorityErrorBody = authorityErrorBody;
32
+ exports.errorStatus = errorStatus;
33
+ exports.accessFailure = accessFailure;
34
+ exports.permissionSet = permissionSet;
35
+ /** Entries kept per reader; the oldest goes first. */
36
+ const CALLER_AUTHORITY_CACHE_MAX = 1000;
37
+ /**
38
+ * The person behind `req`, or why there is none: `service` for a request
39
+ * authenticated by a service token (delegated or not — its bearer is the
40
+ * service's, never the person's), `missing` when `middleware.auth()` resolved
41
+ * no user session.
42
+ */
43
+ function readCallerSession(req) {
44
+ if (req.serviceApp)
45
+ return { ok: false, reason: 'service' };
46
+ const { userId, sessionId, accessToken } = req;
47
+ if (typeof userId !== 'string' ||
48
+ userId.length === 0 ||
49
+ typeof sessionId !== 'string' ||
50
+ sessionId.length === 0 ||
51
+ typeof accessToken !== 'string' ||
52
+ !isBearerToken(accessToken)) {
53
+ return { ok: false, reason: 'missing' };
54
+ }
55
+ return { ok: true, session: { userId, sessionId, accessToken } };
56
+ }
57
+ /** A bearer that can be put in an `Authorization` header unchanged. */
58
+ function isBearerToken(token) {
59
+ return token.length > 0 && !/\s/.test(token);
60
+ }
61
+ /** The typed failure of `asUser`, `accountAccessFor`, `applicationAccessFor` and their middleware. */
62
+ class OxyAccessError extends Error {
63
+ constructor(code, message, options = {}) {
64
+ super(message);
65
+ this.name = 'OxyAccessError';
66
+ this.code = code;
67
+ this.permission = options.permission;
68
+ this.cause = options.cause;
69
+ }
70
+ }
71
+ exports.OxyAccessError = OxyAccessError;
72
+ /**
73
+ * The status for an {@link OxyAccessError}: `deny` (default `403`) for a
74
+ * refusal; `401` for no session (`404` when `deny` is `404`, so a hidden
75
+ * surface stays hidden from the signed-out too); `503` when Oxy could not say.
76
+ */
77
+ function accessErrorStatus(error, deny = 403) {
78
+ switch (error.code) {
79
+ case 'ACCESS_DENIED':
80
+ return deny;
81
+ case 'ACCESS_SESSION_REQUIRED':
82
+ return deny === 404 ? 404 : 401;
83
+ case 'ACCESS_UNAVAILABLE':
84
+ return 503;
85
+ }
86
+ }
87
+ /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
88
+ function authorityErrorBody(status) {
89
+ switch (status) {
90
+ case 401:
91
+ return { error: { code: 'unauthorized', message: 'Authentication required' } };
92
+ case 403:
93
+ return { error: { code: 'forbidden', message: 'Not permitted' } };
94
+ case 404:
95
+ return { error: { code: 'not_found', message: 'Not found' } };
96
+ case 503:
97
+ return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
98
+ default:
99
+ return { error: { code: 'internal_error', message: 'Internal error' } };
100
+ }
101
+ }
102
+ /** The HTTP status an `OxyApiError` (or anything shaped like one) carries. */
103
+ function errorStatus(error) {
104
+ const status = error?.status;
105
+ return typeof status === 'number' ? status : undefined;
106
+ }
107
+ /**
108
+ * Map a failed caller-bearer read of Oxy to its outcome: 401 → session
109
+ * required, 403/404 → denied, everything else → unavailable.
110
+ */
111
+ function accessFailure(error, what) {
112
+ const status = errorStatus(error);
113
+ if (status === 401) {
114
+ return new OxyAccessError('ACCESS_SESSION_REQUIRED', `Oxy refused the session reading ${what}`, {
115
+ cause: error,
116
+ });
117
+ }
118
+ if (status === 403 || status === 404) {
119
+ return new OxyAccessError('ACCESS_DENIED', `Oxy refused ${what} to this session`, {
120
+ cause: error,
121
+ });
122
+ }
123
+ return new OxyAccessError('ACCESS_UNAVAILABLE', `Could not read ${what} from Oxy`, {
124
+ cause: error,
125
+ });
126
+ }
127
+ /**
128
+ * The positive-only answer cache one caller-authority reader keeps, keyed by
129
+ * (resource, session, person). Concurrent identical reads share one lookup; a
130
+ * `fresh` read skips the cache and refreshes it; a slower, OLDER answer never
131
+ * overwrites a newer one (the newer may be the fresh read that saw a
132
+ * revocation); a failure is never stored.
133
+ */
134
+ class CallerAuthorityCache {
135
+ /**
136
+ * @param ttlMs How long an answer is reused; `0` disables the cache.
137
+ * @param isAbout Whether a cached answer concerns `userId` beyond the
138
+ * session's own user (an application-permissions answer names the
139
+ * resolved operator), for {@link invalidate}.
140
+ */
141
+ constructor(ttlMs, isAbout = () => false) {
142
+ this.ttlMs = ttlMs;
143
+ this.isAbout = isAbout;
144
+ this.entries = new Map();
145
+ this.pending = new Map();
146
+ }
147
+ async read(resourceKey, session, fresh, lookup) {
148
+ const key = JSON.stringify([...resourceKey, session.sessionId, session.userId]);
149
+ const ttl = this.ttlMs;
150
+ if (!fresh && ttl > 0) {
151
+ const cached = this.entries.get(key);
152
+ if (cached && cached.expiresAt > Date.now())
153
+ return cached.value;
154
+ const inFlight = this.pending.get(key);
155
+ if (inFlight)
156
+ return inFlight;
157
+ }
158
+ const startedAt = Date.now();
159
+ const answer = lookup().then((value) => {
160
+ if (ttl > 0)
161
+ this.remember(key, session.userId, value, startedAt, ttl);
162
+ return value;
163
+ });
164
+ if (fresh || ttl <= 0)
165
+ return answer;
166
+ this.pending.set(key, answer);
167
+ try {
168
+ return await answer;
169
+ }
170
+ finally {
171
+ if (this.pending.get(key) === answer)
172
+ this.pending.delete(key);
173
+ }
174
+ }
175
+ /** Forget every answer, or those about one person. */
176
+ invalidate(filter = {}) {
177
+ const { userId } = filter;
178
+ if (!userId) {
179
+ this.entries.clear();
180
+ return;
181
+ }
182
+ for (const [key, entry] of this.entries) {
183
+ if (entry.userId === userId || this.isAbout(entry.value, userId))
184
+ this.entries.delete(key);
185
+ }
186
+ }
187
+ remember(key, userId, value, startedAt, ttl) {
188
+ const existing = this.entries.get(key);
189
+ if (existing && existing.startedAt > startedAt)
190
+ return;
191
+ this.entries.delete(key);
192
+ this.entries.set(key, { value, userId, startedAt, expiresAt: startedAt + ttl });
193
+ if (this.entries.size > CALLER_AUTHORITY_CACHE_MAX) {
194
+ const oldest = this.entries.keys().next().value;
195
+ if (oldest !== undefined)
196
+ this.entries.delete(oldest);
197
+ }
198
+ }
199
+ }
200
+ exports.CallerAuthorityCache = CallerAuthorityCache;
201
+ /** A frozen, sorted permission set with `has`. */
202
+ function permissionSet(permissions) {
203
+ const sorted = Object.freeze([...new Set(permissions)].sort());
204
+ const held = new Set(sorted);
205
+ return { permissions: sorted, has: (permission) => held.has(permission) };
206
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.inputSatisfiesCapabilityLimits = exports.CapabilityTicketError = void 0;
4
4
  exports.issueCapabilityTicket = issueCapabilityTicket;
5
5
  exports.verifyCapabilityTicket = verifyCapabilityTicket;
6
+ exports.resolveCapabilityTicketKey = resolveCapabilityTicketKey;
6
7
  exports.readCapabilityAuthorization = readCapabilityAuthorization;
7
8
  exports.createCapabilityTicketMiddleware = createCapabilityTicketMiddleware;
8
9
  const node_crypto_1 = require("node:crypto");
@@ -49,7 +50,7 @@ function issueCapabilityTicket(claims, options) {
49
50
  const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
50
51
  return `${signingInput}.${base64UrlEncode((0, node_crypto_1.sign)(null, Buffer.from(signingInput), options.privateKey))}`;
51
52
  }
52
- function verifyCapabilityTicket(token, options) {
53
+ function parseCapabilityTicket(token) {
53
54
  const segments = token.split('.');
54
55
  if (segments.length !== 3) {
55
56
  throw new CapabilityTicketError('malformed', 'Capability ticket must have three segments');
@@ -70,21 +71,29 @@ function verifyCapabilityTicket(token, options) {
70
71
  headerRecord.kid.length === 0) {
71
72
  throw new CapabilityTicketError('malformed', 'Capability ticket header is not supported');
72
73
  }
73
- const publicKey = options.resolvePublicKey(headerRecord.kid);
74
+ return {
75
+ keyId: headerRecord.kid,
76
+ signingInput: `${encodedHeader}.${encodedPayload}`,
77
+ encodedPayload,
78
+ encodedSignature,
79
+ };
80
+ }
81
+ /** Signature and claims of a parsed ticket against the key its `kid` resolved to. */
82
+ function checkCapabilityTicket(ticket, publicKey, options) {
74
83
  if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
75
84
  throw new CapabilityTicketError('unknown_key', 'Capability ticket signing key is not trusted');
76
85
  }
77
86
  let providedSignature;
78
87
  try {
79
- providedSignature = Buffer.from(encodedSignature, 'base64url');
88
+ providedSignature = Buffer.from(ticket.encodedSignature, 'base64url');
80
89
  }
81
90
  catch {
82
91
  throw new CapabilityTicketError('malformed', 'Capability ticket signature is malformed');
83
92
  }
84
- if (!(0, node_crypto_1.verify)(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), publicKey, providedSignature)) {
93
+ if (!(0, node_crypto_1.verify)(null, Buffer.from(ticket.signingInput), publicKey, providedSignature)) {
85
94
  throw new CapabilityTicketError('invalid_signature', 'Capability ticket signature is invalid');
86
95
  }
87
- const parsed = contracts_1.capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(encodedPayload));
96
+ const parsed = contracts_1.capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(ticket.encodedPayload));
88
97
  if (!parsed.success) {
89
98
  throw new CapabilityTicketError('invalid_claims', 'Capability ticket claims are invalid');
90
99
  }
@@ -107,6 +116,30 @@ function verifyCapabilityTicket(token, options) {
107
116
  }
108
117
  return claims;
109
118
  }
119
+ function verifyCapabilityTicket(token, options) {
120
+ const ticket = parseCapabilityTicket(token);
121
+ return checkCapabilityTicket(ticket, options.resolvePublicKey(ticket.keyId), options);
122
+ }
123
+ /**
124
+ * A verifier for one ticket whose key lookup may be asynchronous: the key is
125
+ * resolved once, and the returned `check` re-verifies signature and claims
126
+ * against it (the live verifier checks again after introspection, so a slow
127
+ * authority answer cannot outlive the ticket).
128
+ *
129
+ * @internal Shared by the middleware and `createLiveCapabilityTicketVerifier`.
130
+ */
131
+ async function resolveCapabilityTicketKey(token, options) {
132
+ const ticket = parseCapabilityTicket(token);
133
+ let publicKey;
134
+ try {
135
+ publicKey = await options.resolvePublicKey(ticket.keyId);
136
+ }
137
+ catch {
138
+ // A key set that cannot be read trusts no key; never a different error class.
139
+ publicKey = undefined;
140
+ }
141
+ return { check: () => checkCapabilityTicket(ticket, publicKey, options) };
142
+ }
110
143
  function readCapabilityAuthorization(authorization) {
111
144
  if (!authorization)
112
145
  return null;
@@ -129,7 +162,7 @@ function createCapabilityTicketMiddleware(options) {
129
162
  }
130
163
  let claims;
131
164
  try {
132
- claims = verifyCapabilityTicket(token, options);
165
+ claims = (await resolveCapabilityTicketKey(token, options)).check();
133
166
  }
134
167
  catch (error) {
135
168
  const code = error instanceof CapabilityTicketError ? error.code : 'invalid_claims';
@@ -27,6 +27,8 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.normalizeAppOrigins = normalizeAppOrigins;
29
29
  exports.matchesAllowedOrigin = matchesAllowedOrigin;
30
+ exports.createOxyOriginCheck = createOxyOriginCheck;
31
+ exports.createOxySocketCors = createOxySocketCors;
30
32
  exports.createOxyCors = createOxyCors;
31
33
  const logger_1 = require("../logger");
32
34
  const authWebUrl_1 = require("../utils/authWebUrl");
@@ -163,6 +165,43 @@ function matchesAllowedOrigin(explicit, origin) {
163
165
  return true;
164
166
  return isOxyFamilyOrigin(normalized);
165
167
  }
168
+ /**
169
+ * The origin predicate `createOxyCors` applies, for any other transport that
170
+ * must admit the same browsers: the built-in HTTPS Oxy apex family plus the
171
+ * exact `appOrigins`, never an opaque origin (`exp://`, `file:`, …).
172
+ *
173
+ * ```ts
174
+ * const allowed = createOxyOriginCheck(['https://app.example.com']);
175
+ * allowed('https://mention.oxy.so'); // true
176
+ * ```
177
+ */
178
+ function createOxyOriginCheck(appOrigins = []) {
179
+ const explicit = normalizeAppOrigins(appOrigins);
180
+ return (origin) => matchesAllowedOrigin(explicit, origin);
181
+ }
182
+ /**
183
+ * The Socket.IO server options that admit exactly the origins `createOxyCors`
184
+ * admits — spread them into `new Server(httpServer, { ...createOxySocketCors(…) })`.
185
+ *
186
+ * Two halves, because Socket.IO has two transports: `cors` answers the
187
+ * long-polling requests (which browsers check), and `allowRequest` refuses a
188
+ * handshake from any other browser origin, which is the only check a WebSocket
189
+ * upgrade gets — browsers apply no CORS to it. A request with no `Origin`
190
+ * header (a native app, a server) is not a browser cross-origin request and is
191
+ * admitted; authenticate it with `oxy.middleware.socket()` as every socket.
192
+ */
193
+ function createOxySocketCors(options = {}) {
194
+ const allowed = createOxyOriginCheck(options.appOrigins);
195
+ const admits = (origin) => origin === undefined || (typeof origin === 'string' && (origin === '' || allowed(origin)));
196
+ return {
197
+ cors: {
198
+ origin: (origin, callback) => callback(null, admits(origin)),
199
+ credentials: options.allowCredentials ?? true,
200
+ methods: ['GET', 'POST'],
201
+ },
202
+ allowRequest: (req, callback) => callback(null, admits(req.headers.origin)),
203
+ };
204
+ }
166
205
  /**
167
206
  * Create a strict Oxy CORS middleware. See module docs.
168
207
  *
@@ -173,7 +212,7 @@ function matchesAllowedOrigin(explicit, origin) {
173
212
  */
174
213
  function createOxyCors(options = {}) {
175
214
  const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
176
- const explicitOrigins = normalizeAppOrigins(appOrigins);
215
+ const allowed = createOxyOriginCheck(appOrigins);
177
216
  const methodsHeader = methods.join(', ');
178
217
  const allowedHeadersHeader = allowedHeaders.join(', ');
179
218
  const exposedHeadersHeader = exposedHeaders.join(', ');
@@ -191,7 +230,7 @@ function createOxyCors(options = {}) {
191
230
  }
192
231
  // Origin is present. Caching correctness: this response varies by Origin.
193
232
  res.setHeader('Vary', 'Origin');
194
- if (!matchesAllowedOrigin(explicitOrigins, origin)) {
233
+ if (!allowed(origin)) {
195
234
  // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
196
235
  // will block the cross-origin read. Preflights for denied origins get a
197
236
  // 204 with no CORS headers (the actual request then fails CORS).
@@ -5,20 +5,39 @@
5
5
  * This subpath export provides Express middleware and Node.js-specific
6
6
  * utilities that are not available in React Native or browser environments.
7
7
  *
8
+ * One `OxyServer` per process, authenticated as the backend's own workload
9
+ * (ADR 0026/0049 — no api key, no client secret):
10
+ *
8
11
  * @example
9
12
  * ```ts
10
- * import { OxyServer, createOxyAuthMiddleware, createOxyRateLimit } from '@oxy.so/core/server';
13
+ * import {
14
+ * OxyServer,
15
+ * createOxyAuthMiddleware,
16
+ * createOxyCors,
17
+ * createOxyRateLimit,
18
+ * createOxySecurityHeaders,
19
+ * oxyTrustProxy,
20
+ * } from '@oxy.so/core/server';
11
21
  *
12
- * const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', serviceAuth: { apiKey, apiSecret } });
22
+ * export const oxy = new OxyServer({
23
+ * baseURL: process.env.OXY_API_URL ?? 'https://api.oxy.so',
24
+ * applicationId: process.env.OXY_APPLICATION_ID,
25
+ * serviceIdentity: 'when-anonymous',
26
+ * });
13
27
  *
28
+ * app.set('trust proxy', oxyTrustProxy);
29
+ * app.use(createOxySecurityHeaders(), createOxyCors({ appOrigins }));
14
30
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
31
  * app.use('/api', createOxyAuthMiddleware(oxy));
32
+ *
33
+ * // As the person behind the request, never on the shared client:
34
+ * const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
16
35
  * ```
17
36
  */
18
37
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.resolveClientIp = exports.oxyTrustProxy = exports.isPrivateProxyAddress = exports.isCloudflareAddress = exports.CLOUDFLARE_IPV6_RANGES = exports.CLOUDFLARE_IPV4_RANGES = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.getOxyAuthRefusal = exports.getOxyActor = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = exports.appPermissionErrorStatus = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = exports.OXY_ACCOUNT_DELETED_EVENT_URI = exports.OxyAccountEventError = exports.OXY_ALIA_MACHINE_SCOPES = exports.OXY_ALIA_RESOURCE_APPLICATION_ID = exports.isOxyAliaMachinePrincipal = exports.getOxyAliaMachineCredentialBearer = exports.createOxyAliaMachineCredentialAuth = exports.ServerSessionApi = exports.ServerSafetyApi = exports.ServerReputationApi = exports.ServerNotificationsApi = exports.ServerLinkedAccountsApi = exports.ServerAssetsApi = exports.ServerAgencyApi = exports.ServerAppsApi = exports.ServerAccountsApi = exports.DEFAULT_SERVICE_TOKEN_AUDIENCE = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = exports.ServiceCredentialMismatchError = exports.OxyServer = void 0;
20
- exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.createLiveCapabilityTicketVerifier = exports.verifySecret = exports.verifyOxyAppAssertion = exports.verifyAppAssertion = exports.signOxyAppAssertion = exports.readOxyAppAssertionKeyId = exports.readAppAssertionHeader = exports.oxyAppAssertionClaimsSchema = exports.OxyAppAssertionError = exports.OXY_APP_ASSERTION_DEFAULT_ISSUER = exports.createOxyJwksKeyResolver = exports.verifyCapabilityTicket = exports.readCapabilityAuthorization = exports.issueCapabilityTicket = exports.inputSatisfiesCapabilityLimits = exports.createCapabilityTicketMiddleware = exports.CapabilityTicketError = exports.OXY_FILES_UPLOAD_ORIGIN = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.LargeUploadFailedError = exports.verifyStreamKeyAuthorization = exports.StreamKeyAuthorizationVerificationError = exports.STREAM_KEY_AUTHORIZATION_EVENT_TYPE = exports.verifyFileEventSignature = exports.FileEventVerificationError = exports.FILE_EVENT_TYPES = exports.FILE_EVENT_SIGNATURE_HEADER = exports.FILE_EVENT_DEFAULT_TOLERANCE_SECONDS = exports.withDeadline = exports.readResponseJson = exports.readResponseBody = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DNS_LOOKUP_TIMEOUT_MS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = void 0;
21
- exports.OxyRequesterClient = exports.signInAgentAccount = exports.hasBoundedServiceTokenLifetime = exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = exports.requestWorkloadServiceToken = exports.canAttestWorkloadIdentity = exports.attestWorkloadIdentity = exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.registrableApex = void 0;
38
+ exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.resolveClientIp = exports.oxyTrustProxy = exports.isPrivateProxyAddress = exports.isCloudflareAddress = exports.CLOUDFLARE_IPV6_RANGES = exports.CLOUDFLARE_IPV4_RANGES = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.getOxyAuthRefusal = exports.getOxyActor = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = exports.appPermissionErrorStatus = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = exports.OXY_ACCOUNT_DELETED_EVENT_URI = exports.OxyAccountEventError = exports.OXY_ALIA_MACHINE_SCOPES = exports.OXY_ALIA_RESOURCE_APPLICATION_ID = exports.isOxyAliaMachinePrincipal = exports.getOxyAliaMachineCredentialBearer = exports.createOxyAliaMachineCredentialAuth = exports.ServerSessionApi = exports.ServerSafetyApi = exports.ServerReputationApi = exports.ServerNotificationsApi = exports.ServerLinkedAccountsApi = exports.ServerAssetsApi = exports.ServerAgencyApi = exports.ServerAppsApi = exports.ServerAccountsApi = exports.accessErrorStatus = exports.OxyAccessError = exports.OxyUserClient = exports.DEFAULT_SERVICE_TOKEN_AUDIENCE = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = exports.ServiceCredentialMismatchError = exports.OxyServer = void 0;
39
+ exports.createLiveCapabilityTicketVerifier = exports.verifySecret = exports.verifyOxyAppAssertion = exports.verifyAppAssertion = exports.signOxyAppAssertion = exports.readOxyAppAssertionKeyId = exports.readAppAssertionHeader = exports.oxyAppAssertionClaimsSchema = exports.OxyAppAssertionError = exports.OXY_APP_ASSERTION_DEFAULT_ISSUER = exports.createOxyJwksKeyResolver = exports.verifyCapabilityTicket = exports.readCapabilityAuthorization = exports.issueCapabilityTicket = exports.inputSatisfiesCapabilityLimits = exports.createCapabilityTicketMiddleware = exports.CapabilityTicketError = exports.OXY_FILES_UPLOAD_ORIGIN = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxySocketCors = exports.createOxyOriginCheck = exports.createOxyCors = exports.LargeUploadFailedError = exports.verifyStreamKeyAuthorization = exports.StreamKeyAuthorizationVerificationError = exports.STREAM_KEY_AUTHORIZATION_EVENT_TYPE = exports.verifyFileEventSignature = exports.FileEventVerificationError = exports.FILE_EVENT_TYPES = exports.FILE_EVENT_SIGNATURE_HEADER = exports.FILE_EVENT_DEFAULT_TOLERANCE_SECONDS = exports.withDeadline = exports.readResponseJson = exports.readResponseBody = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DNS_LOOKUP_TIMEOUT_MS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = void 0;
40
+ exports.OxyRequesterClient = exports.signInAgentAccount = exports.hasBoundedServiceTokenLifetime = exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = exports.requestWorkloadServiceToken = exports.canAttestWorkloadIdentity = exports.attestWorkloadIdentity = exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = void 0;
22
41
  // The backend client: OxyServices plus the service-token lane, middleware,
23
42
  // account events and the service-only namespace methods.
24
43
  var OxyServer_1 = require("./OxyServer");
@@ -26,6 +45,13 @@ Object.defineProperty(exports, "OxyServer", { enumerable: true, get: function ()
26
45
  Object.defineProperty(exports, "ServiceCredentialMismatchError", { enumerable: true, get: function () { return OxyServer_1.ServiceCredentialMismatchError; } });
27
46
  Object.defineProperty(exports, "ANONYMOUS_SERVICE_TOKEN_RETRY_MS", { enumerable: true, get: function () { return OxyServer_1.ANONYMOUS_SERVICE_TOKEN_RETRY_MS; } });
28
47
  Object.defineProperty(exports, "DEFAULT_SERVICE_TOKEN_AUDIENCE", { enumerable: true, get: function () { return OxyServer_1.DEFAULT_SERVICE_TOKEN_AUDIENCE; } });
48
+ // The requesting person: an isolated client bound to their bearer, and what
49
+ // they may do in an account or over an application (with typed outcomes).
50
+ var userClient_1 = require("./userClient");
51
+ Object.defineProperty(exports, "OxyUserClient", { enumerable: true, get: function () { return userClient_1.OxyUserClient; } });
52
+ var callerAuthority_1 = require("./callerAuthority");
53
+ Object.defineProperty(exports, "OxyAccessError", { enumerable: true, get: function () { return callerAuthority_1.OxyAccessError; } });
54
+ Object.defineProperty(exports, "accessErrorStatus", { enumerable: true, get: function () { return callerAuthority_1.accessErrorStatus; } });
29
55
  var namespaces_1 = require("./namespaces");
30
56
  Object.defineProperty(exports, "ServerAccountsApi", { enumerable: true, get: function () { return namespaces_1.ServerAccountsApi; } });
31
57
  Object.defineProperty(exports, "ServerAppsApi", { enumerable: true, get: function () { return namespaces_1.ServerAppsApi; } });
@@ -116,6 +142,8 @@ Object.defineProperty(exports, "LargeUploadFailedError", { enumerable: true, get
116
142
  // Strict CORS allowlist (Oxy apex family + explicit app origins).
117
143
  var cors_1 = require("./cors");
118
144
  Object.defineProperty(exports, "createOxyCors", { enumerable: true, get: function () { return cors_1.createOxyCors; } });
145
+ Object.defineProperty(exports, "createOxyOriginCheck", { enumerable: true, get: function () { return cors_1.createOxyOriginCheck; } });
146
+ Object.defineProperty(exports, "createOxySocketCors", { enumerable: true, get: function () { return cors_1.createOxySocketCors; } });
119
147
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
120
148
  // Oxy API/CDN origins) with additive, per-app extensions.
121
149
  //
@@ -13,7 +13,9 @@ function createLiveCapabilityTicketVerifier(options) {
13
13
  }
14
14
  return async (ticket, input = {}) => {
15
15
  input.signal?.throwIfAborted();
16
- const signed = (0, capabilityTicket_1.verifyCapabilityTicket)(ticket, options);
16
+ const verifier = await (0, capabilityTicket_1.resolveCapabilityTicketKey)(ticket, options);
17
+ input.signal?.throwIfAborted();
18
+ const signed = verifier.check();
17
19
  if (!signed.catalog)
18
20
  throw new Error('Internal MCP requires a signed catalogue binding');
19
21
  const controller = new AbortController();
@@ -39,7 +41,7 @@ function createLiveCapabilityTicketVerifier(options) {
39
41
  if ((0, contracts_1.canonicalCapabilityJson)(live) !== (0, contracts_1.canonicalCapabilityJson)(signed))
40
42
  throw new Error('Capability introspection claims mismatch');
41
43
  // An authority response delayed past expiry cannot extend the signed grant.
42
- return (0, capabilityTicket_1.verifyCapabilityTicket)(ticket, options);
44
+ return verifier.check();
43
45
  }
44
46
  finally {
45
47
  clearTimeout(timer);
@@ -6,6 +6,7 @@ exports.parseJsonSegment = parseJsonSegment;
6
6
  exports.resolveServiceTokenPublicKey = resolveServiceTokenPublicKey;
7
7
  const serviceTokenLifetime_1 = require("./serviceTokenLifetime");
8
8
  const appPermissions_1 = require("./appPermissions");
9
+ const callerAuthority_1 = require("./callerAuthority");
9
10
  const contracts_1 = require("@oxy.so/contracts");
10
11
  const jwt_decode_1 = require("jwt-decode");
11
12
  const protocol_1 = require("@oxy.so/protocol");
@@ -137,7 +138,7 @@ function createOxyMiddleware(host) {
137
138
  * @returns Express middleware function
138
139
  */
139
140
  function auth(options = {}) {
140
- const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = exports.OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
141
+ const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = exports.OXY_JWT_ISSUER, expectedAudience = host.defaultAudience ?? OXY_JWT_AUDIENCE, } = options;
141
142
  const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
142
143
  /**
143
144
  * Make a refusal observable to the HOST, without telling the client
@@ -1173,7 +1174,108 @@ function createOxyMiddleware(host) {
1173
1174
  res.status(status).json((0, appPermissions_1.appPermissionErrorBody)(status));
1174
1175
  };
1175
1176
  }
1176
- return { auth, socket, service, requireScope, requireAppPermission };
1177
+ /**
1178
+ * One gate for both caller-access readers: read the id from the route
1179
+ * parameter, ask, admit on `permission`, answer a refusal by its outcome.
1180
+ */
1181
+ function requireCallerAccess(method, permission, options, read, admit) {
1182
+ const { param, deny = 403, respond = true, ...lookup } = options;
1183
+ if (typeof permission !== 'string' || permission.trim() === '') {
1184
+ throw new Error(`${method}: permission must be a non-empty string`);
1185
+ }
1186
+ if (typeof param !== 'string' || param.trim() === '') {
1187
+ throw new Error(`${method}: param must name the route parameter that holds the id`);
1188
+ }
1189
+ if (deny !== 403 && deny !== 404) {
1190
+ throw new Error(`${method}: deny must be 403 or 404`);
1191
+ }
1192
+ return async (req, res, next) => {
1193
+ let refusal;
1194
+ const id = req.params?.[param];
1195
+ try {
1196
+ if (typeof id !== 'string' || id === '') {
1197
+ throw new callerAuthority_1.OxyAccessError('ACCESS_DENIED', `No ${param} on this route`);
1198
+ }
1199
+ const access = await read(req, id, lookup);
1200
+ if (access.has(permission)) {
1201
+ admit(req, access);
1202
+ next();
1203
+ return;
1204
+ }
1205
+ refusal = new callerAuthority_1.OxyAccessError('ACCESS_DENIED', `Missing permission ${permission}`, {
1206
+ permission,
1207
+ });
1208
+ }
1209
+ catch (error) {
1210
+ if (!(error instanceof callerAuthority_1.OxyAccessError)) {
1211
+ next(error);
1212
+ return;
1213
+ }
1214
+ refusal = new callerAuthority_1.OxyAccessError(error.code, error.message, {
1215
+ permission,
1216
+ cause: error.cause ?? error,
1217
+ });
1218
+ }
1219
+ if (refusal.code === 'ACCESS_UNAVAILABLE') {
1220
+ logger_1.logger.warn(`[oxy.auth] ${method} could not ask Oxy`, {
1221
+ component: 'auth',
1222
+ method,
1223
+ permission,
1224
+ path: req.path,
1225
+ });
1226
+ }
1227
+ if (!respond) {
1228
+ next(refusal);
1229
+ return;
1230
+ }
1231
+ const status = (0, callerAuthority_1.accessErrorStatus)(refusal, deny);
1232
+ res.status(status).json((0, callerAuthority_1.authorityErrorBody)(status));
1233
+ };
1234
+ }
1235
+ /**
1236
+ * Express middleware: the person behind the request must hold `permission`
1237
+ * in the Oxy ACCOUNT named by the route parameter `param` (an organization,
1238
+ * store, channel). Mount after `auth()`. On success the answer is on
1239
+ * `req.oxyAccountAccess`. See `OxyServer.accountAccessFor`.
1240
+ *
1241
+ * @example
1242
+ * ```typescript
1243
+ * router.patch('/stores/:storeId', oxy.middleware.requireAccountAccess('account:update', { param: 'storeId', fresh: true }), handler);
1244
+ * ```
1245
+ */
1246
+ function requireAccountAccess(permission, options) {
1247
+ return requireCallerAccess('requireAccountAccess', permission, options, (req, id, lookup) => host.accountAccessFor(req, id, lookup), (req, access) => {
1248
+ req.oxyAccountAccess = access;
1249
+ });
1250
+ }
1251
+ /**
1252
+ * Express middleware: the person behind the request must hold the
1253
+ * application permission `permission` (`app:read`, `app:update`,
1254
+ * `webhooks:update`, …) over the APPLICATION named by the route parameter
1255
+ * `param`, through the account that owns it. Mount after `auth()`. On
1256
+ * success the answer is on `req.oxyApplicationAccess`. See
1257
+ * `OxyServer.applicationAccessFor`; for THIS backend's own operator keys use
1258
+ * `requireAppPermission` instead.
1259
+ *
1260
+ * @example
1261
+ * ```typescript
1262
+ * router.get('/apps/:appId/payments', oxy.middleware.requireApplicationAccess('app:read', { param: 'appId' }), handler);
1263
+ * ```
1264
+ */
1265
+ function requireApplicationAccess(permission, options) {
1266
+ return requireCallerAccess('requireApplicationAccess', permission, options, (req, id, lookup) => host.applicationAccessFor(req, id, lookup), (req, access) => {
1267
+ req.oxyApplicationAccess = access;
1268
+ });
1269
+ }
1270
+ return {
1271
+ auth,
1272
+ socket,
1273
+ service,
1274
+ requireScope,
1275
+ requireAppPermission,
1276
+ requireAccountAccess,
1277
+ requireApplicationAccess,
1278
+ };
1177
1279
  }
1178
1280
  function isExactNonEmptyServiceClaim(value) {
1179
1281
  return typeof value === 'string' && value.length > 0 && value.trim() === value;
@@ -61,6 +61,14 @@ const SERVICE_RECONCILE_RATE_LIMIT_MAX_WAIT_MS = 60000;
61
61
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
62
62
  /** A header value fetch accepts on every runtime: printable ASCII only. */
63
63
  const HEADER_SAFE_PATTERN = /^[\x20-\x7e]*$/;
64
+ /** The `RequestOptions` half of {@link ServiceCallOptions}, only the fields that were set. */
65
+ function serviceCallRequestOptions(options) {
66
+ return {
67
+ ...(options.signal ? { signal: options.signal } : {}),
68
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
69
+ ...(options.retry !== undefined ? { retry: options.retry } : {}),
70
+ };
71
+ }
64
72
  /** The headers both streamed service uploads share. */
65
73
  function uploadHeaders(mime, description) {
66
74
  return {
@@ -151,10 +159,13 @@ class ServerAssetsApi extends assets_1.AssetsApi {
151
159
  * changes the file's visibility.
152
160
  * - `remove`: only references this application holds. Never trashes or
153
161
  * deletes the file.
162
+ *
163
+ * Both take {@link ServiceCallOptions} for each request they send — e.g.
164
+ * `{ retry: false, timeout: 5000 }` from an outbox that retries by itself.
154
165
  */
155
166
  this.references = {
156
- add: (references) => this.sendReferences('POST', references),
157
- remove: (references) => this.sendReferences('DELETE', references),
167
+ add: (references, options = {}) => this.sendReferences('POST', references, options),
168
+ remove: (references, options = {}) => this.sendReferences('DELETE', references, options),
158
169
  /**
159
170
  * Reconcile this application's references of ONE entity type with its
160
171
  * real inventory (ADR 0043 D6) — the safety net for a reference a crash or
@@ -313,13 +324,13 @@ class ServerAssetsApi extends assets_1.AssetsApi {
313
324
  await release();
314
325
  return result;
315
326
  }
316
- async sendReferences(method, references) {
327
+ async sendReferences(method, references, options) {
317
328
  const results = [];
318
329
  for (let start = 0; start < references.length; start += SERVICE_REFERENCES_CHUNK_SIZE) {
319
330
  const chunk = references
320
331
  .slice(start, start + SERVICE_REFERENCES_CHUNK_SIZE)
321
332
  .map(({ fileId, entityType, entityId }) => ({ fileId, entityType, entityId }));
322
- const res = await lane(this.ctx).request(method, '/assets/service/references', { references: chunk }, { cache: false });
333
+ const res = await lane(this.ctx).request(method, '/assets/service/references', { references: chunk }, { ...serviceCallRequestOptions(options), cache: false });
323
334
  results.push(...res.results);
324
335
  }
325
336
  return results;