@oxy.so/core 7.15.1 → 7.17.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 +151 -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 +145 -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 +86 -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 +5 -5
  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 +232 -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,267 @@
1
+ /**
2
+ * What every "may the person behind this request …?" read on `OxyServer`
3
+ * shares: which requests carry a person at all, the one failure type and its
4
+ * status mapping, the response envelope, and the positive-only answer cache.
5
+ *
6
+ * The readers built on it answer different questions — keep them apart:
7
+ *
8
+ * | Question | Reader |
9
+ * |---|---|
10
+ * | Does this person hold one of MY application's declared operator keys (`payments.operate`)? | `appPermissionsFor` (`./appPermissions`) |
11
+ * | What may this person do in Oxy account X (an organization, store, channel)? | `accountAccessFor` (`./callerAccess`) |
12
+ * | What may this person do to application A, through the account that owns it (`app:read`, `app:update`)? | `applicationAccessFor` (`./callerAccess`) |
13
+ *
14
+ * ## The outcome mapping, and why an outage is never a "no"
15
+ *
16
+ * Every reader asks Oxy with the request's OWN verified bearer and maps the
17
+ * answer the same way: a 401 means the session is gone (sign in again); a 403
18
+ * or 404 is Oxy's definitive refusal; anything else — a 5xx, a timeout, a
19
+ * malformed body — is UNAVAILABLE, answered 503. Only a positive answer is
20
+ * cached, so a blip can never be remembered as a refusal, and a refusal is
21
+ * never remembered at all (the next request asks again).
22
+ *
23
+ * Node only; reachable solely from `@oxy.so/core/server`.
24
+ */
25
+
26
+ /** Entries kept per reader; the oldest goes first. */
27
+ const CALLER_AUTHORITY_CACHE_MAX = 1000;
28
+
29
+ /** The request fields a caller-authority read uses — all set by `middleware.auth()`. */
30
+ export interface OxyCallerRequest {
31
+ userId?: string | null;
32
+ sessionId?: string | null;
33
+ accessToken?: string;
34
+ /** Set when the request is authenticated by a SERVICE token; such a request carries no person. */
35
+ serviceApp?: unknown;
36
+ }
37
+
38
+ /** The verified person behind a request. */
39
+ export interface OxyCallerSession {
40
+ readonly userId: string;
41
+ readonly sessionId: string;
42
+ readonly accessToken: string;
43
+ }
44
+
45
+ /**
46
+ * The person behind `req`, or why there is none: `service` for a request
47
+ * authenticated by a service token (delegated or not — its bearer is the
48
+ * service's, never the person's), `missing` when `middleware.auth()` resolved
49
+ * no user session.
50
+ */
51
+ export function readCallerSession(
52
+ req: OxyCallerRequest,
53
+ ): { ok: true; session: OxyCallerSession } | { ok: false; reason: 'service' | 'missing' } {
54
+ if (req.serviceApp) return { ok: false, reason: 'service' };
55
+ const { userId, sessionId, accessToken } = req;
56
+ if (
57
+ typeof userId !== 'string' ||
58
+ userId.length === 0 ||
59
+ typeof sessionId !== 'string' ||
60
+ sessionId.length === 0 ||
61
+ typeof accessToken !== 'string' ||
62
+ !isBearerToken(accessToken)
63
+ ) {
64
+ return { ok: false, reason: 'missing' };
65
+ }
66
+ return { ok: true, session: { userId, sessionId, accessToken } };
67
+ }
68
+
69
+ /** A bearer that can be put in an `Authorization` header unchanged. */
70
+ export function isBearerToken(token: string): boolean {
71
+ return token.length > 0 && !/\s/.test(token);
72
+ }
73
+
74
+ /**
75
+ * - `ACCESS_SESSION_REQUIRED` — no person on the request (anonymous, or a
76
+ * service token), or Oxy refused the session (401). Ask them to sign in.
77
+ * - `ACCESS_DENIED` — Oxy answered "no" (403, 404), or the answer lacks the
78
+ * permission a gate required. Definitive.
79
+ * - `ACCESS_UNAVAILABLE` — Oxy could not be asked or gave no usable answer.
80
+ * Fails closed, answered 503, never cached, never a "no".
81
+ */
82
+ export type OxyAccessErrorCode = 'ACCESS_SESSION_REQUIRED' | 'ACCESS_DENIED' | 'ACCESS_UNAVAILABLE';
83
+
84
+ /** The typed failure of `asUser`, `accountAccessFor`, `applicationAccessFor` and their middleware. */
85
+ export class OxyAccessError extends Error {
86
+ readonly code: OxyAccessErrorCode;
87
+ /** The permission a gate required, when a gate refused. */
88
+ readonly permission: string | undefined;
89
+ /** The underlying failure (an `OxyApiError`), if any. Never sent to a client. */
90
+ readonly cause: unknown;
91
+
92
+ constructor(
93
+ code: OxyAccessErrorCode,
94
+ message: string,
95
+ options: { permission?: string; cause?: unknown } = {},
96
+ ) {
97
+ super(message);
98
+ this.name = 'OxyAccessError';
99
+ this.code = code;
100
+ this.permission = options.permission;
101
+ this.cause = options.cause;
102
+ }
103
+ }
104
+
105
+ /** What a refused request is answered with. */
106
+ export type OxyAccessDenyStatus = 403 | 404;
107
+
108
+ /**
109
+ * The status for an {@link OxyAccessError}: `deny` (default `403`) for a
110
+ * refusal; `401` for no session (`404` when `deny` is `404`, so a hidden
111
+ * surface stays hidden from the signed-out too); `503` when Oxy could not say.
112
+ */
113
+ export function accessErrorStatus(
114
+ error: OxyAccessError,
115
+ deny: OxyAccessDenyStatus = 403,
116
+ ): 401 | 403 | 404 | 503 {
117
+ switch (error.code) {
118
+ case 'ACCESS_DENIED':
119
+ return deny;
120
+ case 'ACCESS_SESSION_REQUIRED':
121
+ return deny === 404 ? 404 : 401;
122
+ case 'ACCESS_UNAVAILABLE':
123
+ return 503;
124
+ }
125
+ }
126
+
127
+ /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
128
+ export function authorityErrorBody(status: number): {
129
+ error: { code: string; message: string };
130
+ } {
131
+ switch (status) {
132
+ case 401:
133
+ return { error: { code: 'unauthorized', message: 'Authentication required' } };
134
+ case 403:
135
+ return { error: { code: 'forbidden', message: 'Not permitted' } };
136
+ case 404:
137
+ return { error: { code: 'not_found', message: 'Not found' } };
138
+ case 503:
139
+ return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
140
+ default:
141
+ return { error: { code: 'internal_error', message: 'Internal error' } };
142
+ }
143
+ }
144
+
145
+ /** The HTTP status an `OxyApiError` (or anything shaped like one) carries. */
146
+ export function errorStatus(error: unknown): number | undefined {
147
+ const status = (error as { status?: unknown } | null)?.status;
148
+ return typeof status === 'number' ? status : undefined;
149
+ }
150
+
151
+ /**
152
+ * Map a failed caller-bearer read of Oxy to its outcome: 401 → session
153
+ * required, 403/404 → denied, everything else → unavailable.
154
+ */
155
+ export function accessFailure(error: unknown, what: string): OxyAccessError {
156
+ const status = errorStatus(error);
157
+ if (status === 401) {
158
+ return new OxyAccessError(
159
+ 'ACCESS_SESSION_REQUIRED',
160
+ `Oxy refused the session reading ${what}`,
161
+ {
162
+ cause: error,
163
+ },
164
+ );
165
+ }
166
+ if (status === 403 || status === 404) {
167
+ return new OxyAccessError('ACCESS_DENIED', `Oxy refused ${what} to this session`, {
168
+ cause: error,
169
+ });
170
+ }
171
+ return new OxyAccessError('ACCESS_UNAVAILABLE', `Could not read ${what} from Oxy`, {
172
+ cause: error,
173
+ });
174
+ }
175
+
176
+ interface CacheEntry<T> {
177
+ value: T;
178
+ userId: string;
179
+ startedAt: number;
180
+ expiresAt: number;
181
+ }
182
+
183
+ /**
184
+ * The positive-only answer cache one caller-authority reader keeps, keyed by
185
+ * (resource, session, person). Concurrent identical reads share one lookup; a
186
+ * `fresh` read skips the cache and refreshes it; a slower, OLDER answer never
187
+ * overwrites a newer one (the newer may be the fresh read that saw a
188
+ * revocation); a failure is never stored.
189
+ */
190
+ export class CallerAuthorityCache<T> {
191
+ private readonly entries = new Map<string, CacheEntry<T>>();
192
+ private readonly pending = new Map<string, Promise<T>>();
193
+
194
+ /**
195
+ * @param ttlMs How long an answer is reused; `0` disables the cache.
196
+ * @param isAbout Whether a cached answer concerns `userId` beyond the
197
+ * session's own user (an application-permissions answer names the
198
+ * resolved operator), for {@link invalidate}.
199
+ */
200
+ constructor(
201
+ private readonly ttlMs: number,
202
+ private readonly isAbout: (value: T, userId: string) => boolean = () => false,
203
+ ) {}
204
+
205
+ async read(
206
+ resourceKey: readonly string[],
207
+ session: OxyCallerSession,
208
+ fresh: boolean,
209
+ lookup: () => Promise<T>,
210
+ ): Promise<T> {
211
+ const key = JSON.stringify([...resourceKey, session.sessionId, session.userId]);
212
+ const ttl = this.ttlMs;
213
+ if (!fresh && ttl > 0) {
214
+ const cached = this.entries.get(key);
215
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
216
+ const inFlight = this.pending.get(key);
217
+ if (inFlight) return inFlight;
218
+ }
219
+
220
+ const startedAt = Date.now();
221
+ const answer = lookup().then((value) => {
222
+ if (ttl > 0) this.remember(key, session.userId, value, startedAt, ttl);
223
+ return value;
224
+ });
225
+ if (fresh || ttl <= 0) return answer;
226
+
227
+ this.pending.set(key, answer);
228
+ try {
229
+ return await answer;
230
+ } finally {
231
+ if (this.pending.get(key) === answer) this.pending.delete(key);
232
+ }
233
+ }
234
+
235
+ /** Forget every answer, or those about one person. */
236
+ invalidate(filter: { userId?: string } = {}): void {
237
+ const { userId } = filter;
238
+ if (!userId) {
239
+ this.entries.clear();
240
+ return;
241
+ }
242
+ for (const [key, entry] of this.entries) {
243
+ if (entry.userId === userId || this.isAbout(entry.value, userId)) this.entries.delete(key);
244
+ }
245
+ }
246
+
247
+ private remember(key: string, userId: string, value: T, startedAt: number, ttl: number): void {
248
+ const existing = this.entries.get(key);
249
+ if (existing && existing.startedAt > startedAt) return;
250
+ this.entries.delete(key);
251
+ this.entries.set(key, { value, userId, startedAt, expiresAt: startedAt + ttl });
252
+ if (this.entries.size > CALLER_AUTHORITY_CACHE_MAX) {
253
+ const oldest = this.entries.keys().next().value;
254
+ if (oldest !== undefined) this.entries.delete(oldest);
255
+ }
256
+ }
257
+ }
258
+
259
+ /** A frozen, sorted permission set with `has`. */
260
+ export function permissionSet(permissions: readonly string[]): {
261
+ readonly permissions: readonly string[];
262
+ has(permission: string): boolean;
263
+ } {
264
+ const sorted = Object.freeze([...new Set(permissions)].sort());
265
+ const held = new Set(sorted);
266
+ return { permissions: sorted, has: (permission: string) => held.has(permission) };
267
+ }
@@ -29,11 +29,28 @@ export interface CapabilityTicketVerificationOptions {
29
29
  maximumTtlSeconds?: number;
30
30
  }
31
31
 
32
+ /**
33
+ * A signing-key lookup that may be asynchronous — `createOxyJwksKeyResolver`
34
+ * is — for the verifiers that already run asynchronously (the middleware and
35
+ * `createLiveCapabilityTicketVerifier`). The ticket's own `kid` is passed; no
36
+ * caller needs to decode the header first.
37
+ */
38
+ export type CapabilityTicketKeyResolver = (
39
+ keyId: string,
40
+ ) => KeyObject | undefined | Promise<KeyObject | undefined>;
41
+
42
+ /** {@link CapabilityTicketVerificationOptions} with a key lookup that may be asynchronous. */
43
+ export interface AsyncCapabilityTicketVerificationOptions
44
+ extends Omit<CapabilityTicketVerificationOptions, 'resolvePublicKey'> {
45
+ resolvePublicKey: CapabilityTicketKeyResolver;
46
+ }
47
+
32
48
  export interface CapabilityTicketRequest extends Request {
33
49
  capabilityTicket?: CapabilityTicketClaims;
34
50
  }
35
51
 
36
- export interface CapabilityTicketMiddlewareOptions extends CapabilityTicketVerificationOptions {
52
+ export interface CapabilityTicketMiddlewareOptions
53
+ extends AsyncCapabilityTicketVerificationOptions {
37
54
  authorize: (claims: CapabilityTicketClaims) => Promise<PolicyDecision>;
38
55
  }
39
56
 
@@ -99,10 +116,15 @@ export function issueCapabilityTicket(
99
116
  return `${signingInput}.${base64UrlEncode(signBytes(null, Buffer.from(signingInput), options.privateKey))}`;
100
117
  }
101
118
 
102
- export function verifyCapabilityTicket(
103
- token: string,
104
- options: CapabilityTicketVerificationOptions,
105
- ): CapabilityTicketClaims {
119
+ /** A ticket split and its header checked, before any key is looked up. */
120
+ interface ParsedCapabilityTicket {
121
+ readonly keyId: string;
122
+ readonly signingInput: string;
123
+ readonly encodedPayload: string;
124
+ readonly encodedSignature: string;
125
+ }
126
+
127
+ function parseCapabilityTicket(token: string): ParsedCapabilityTicket {
106
128
  const segments = token.split('.');
107
129
  if (segments.length !== 3) {
108
130
  throw new CapabilityTicketError('malformed', 'Capability ticket must have three segments');
@@ -126,28 +148,34 @@ export function verifyCapabilityTicket(
126
148
  ) {
127
149
  throw new CapabilityTicketError('malformed', 'Capability ticket header is not supported');
128
150
  }
129
- const publicKey = options.resolvePublicKey(headerRecord.kid);
151
+ return {
152
+ keyId: headerRecord.kid,
153
+ signingInput: `${encodedHeader}.${encodedPayload}`,
154
+ encodedPayload,
155
+ encodedSignature,
156
+ };
157
+ }
158
+
159
+ /** Signature and claims of a parsed ticket against the key its `kid` resolved to. */
160
+ function checkCapabilityTicket(
161
+ ticket: ParsedCapabilityTicket,
162
+ publicKey: KeyObject | undefined,
163
+ options: Omit<CapabilityTicketVerificationOptions, 'resolvePublicKey'>,
164
+ ): CapabilityTicketClaims {
130
165
  if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
131
166
  throw new CapabilityTicketError('unknown_key', 'Capability ticket signing key is not trusted');
132
167
  }
133
168
  let providedSignature: Buffer;
134
169
  try {
135
- providedSignature = Buffer.from(encodedSignature, 'base64url');
170
+ providedSignature = Buffer.from(ticket.encodedSignature, 'base64url');
136
171
  } catch {
137
172
  throw new CapabilityTicketError('malformed', 'Capability ticket signature is malformed');
138
173
  }
139
- if (
140
- !verifyBytes(
141
- null,
142
- Buffer.from(`${encodedHeader}.${encodedPayload}`),
143
- publicKey,
144
- providedSignature,
145
- )
146
- ) {
174
+ if (!verifyBytes(null, Buffer.from(ticket.signingInput), publicKey, providedSignature)) {
147
175
  throw new CapabilityTicketError('invalid_signature', 'Capability ticket signature is invalid');
148
176
  }
149
177
 
150
- const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(encodedPayload));
178
+ const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(ticket.encodedPayload));
151
179
  if (!parsed.success) {
152
180
  throw new CapabilityTicketError('invalid_claims', 'Capability ticket claims are invalid');
153
181
  }
@@ -179,6 +207,37 @@ export function verifyCapabilityTicket(
179
207
  return claims;
180
208
  }
181
209
 
210
+ export function verifyCapabilityTicket(
211
+ token: string,
212
+ options: CapabilityTicketVerificationOptions,
213
+ ): CapabilityTicketClaims {
214
+ const ticket = parseCapabilityTicket(token);
215
+ return checkCapabilityTicket(ticket, options.resolvePublicKey(ticket.keyId), options);
216
+ }
217
+
218
+ /**
219
+ * A verifier for one ticket whose key lookup may be asynchronous: the key is
220
+ * resolved once, and the returned `check` re-verifies signature and claims
221
+ * against it (the live verifier checks again after introspection, so a slow
222
+ * authority answer cannot outlive the ticket).
223
+ *
224
+ * @internal Shared by the middleware and `createLiveCapabilityTicketVerifier`.
225
+ */
226
+ export async function resolveCapabilityTicketKey(
227
+ token: string,
228
+ options: AsyncCapabilityTicketVerificationOptions,
229
+ ): Promise<{ check: () => CapabilityTicketClaims }> {
230
+ const ticket = parseCapabilityTicket(token);
231
+ let publicKey: KeyObject | undefined;
232
+ try {
233
+ publicKey = await options.resolvePublicKey(ticket.keyId);
234
+ } catch {
235
+ // A key set that cannot be read trusts no key; never a different error class.
236
+ publicKey = undefined;
237
+ }
238
+ return { check: () => checkCapabilityTicket(ticket, publicKey, options) };
239
+ }
240
+
182
241
  export function readCapabilityAuthorization(authorization: string | undefined): string | null {
183
242
  if (!authorization) return null;
184
243
  const [scheme, token, extra] = authorization.trim().split(/\s+/);
@@ -203,7 +262,7 @@ export function createCapabilityTicketMiddleware(
203
262
 
204
263
  let claims: CapabilityTicketClaims;
205
264
  try {
206
- claims = verifyCapabilityTicket(token, options);
265
+ claims = (await resolveCapabilityTicketKey(token, options)).check();
207
266
  } catch (error) {
208
267
  const code = error instanceof CapabilityTicketError ? error.code : 'invalid_claims';
209
268
  response.status(401).json({ error: 'invalid_capability_ticket', code });
@@ -197,6 +197,67 @@ export function matchesAllowedOrigin(explicit: ReadonlySet<string>, origin: stri
197
197
  return isOxyFamilyOrigin(normalized);
198
198
  }
199
199
 
200
+ /**
201
+ * The origin predicate `createOxyCors` applies, for any other transport that
202
+ * must admit the same browsers: the built-in HTTPS Oxy apex family plus the
203
+ * exact `appOrigins`, never an opaque origin (`exp://`, `file:`, …).
204
+ *
205
+ * ```ts
206
+ * const allowed = createOxyOriginCheck(['https://app.example.com']);
207
+ * allowed('https://mention.oxy.so'); // true
208
+ * ```
209
+ */
210
+ export function createOxyOriginCheck(appOrigins: string[] = []): (origin: string) => boolean {
211
+ const explicit = normalizeAppOrigins(appOrigins);
212
+ return (origin: string) => matchesAllowedOrigin(explicit, origin);
213
+ }
214
+
215
+ /** Options of {@link createOxySocketCors}. */
216
+ export interface OxySocketCorsOptions {
217
+ /** Explicit additional allowed origins, as {@link OxyCorsOptions.appOrigins}. */
218
+ appOrigins?: string[];
219
+ /** Whether the polling transport sends `Access-Control-Allow-Credentials: true`. Default `true`. */
220
+ allowCredentials?: boolean;
221
+ }
222
+
223
+ /**
224
+ * The Socket.IO server options that admit exactly the origins `createOxyCors`
225
+ * admits — spread them into `new Server(httpServer, { ...createOxySocketCors(…) })`.
226
+ *
227
+ * Two halves, because Socket.IO has two transports: `cors` answers the
228
+ * long-polling requests (which browsers check), and `allowRequest` refuses a
229
+ * handshake from any other browser origin, which is the only check a WebSocket
230
+ * upgrade gets — browsers apply no CORS to it. A request with no `Origin`
231
+ * header (a native app, a server) is not a browser cross-origin request and is
232
+ * admitted; authenticate it with `oxy.middleware.socket()` as every socket.
233
+ */
234
+ export function createOxySocketCors(options: OxySocketCorsOptions = {}): {
235
+ cors: {
236
+ origin: (
237
+ origin: string | undefined,
238
+ callback: (error: Error | null, allow?: boolean) => void,
239
+ ) => void;
240
+ credentials: boolean;
241
+ methods: string[];
242
+ };
243
+ allowRequest: (
244
+ req: { headers: Record<string, string | string[] | undefined> },
245
+ callback: (error: string | null | undefined, success: boolean) => void,
246
+ ) => void;
247
+ } {
248
+ const allowed = createOxyOriginCheck(options.appOrigins);
249
+ const admits = (origin: string | string[] | undefined): boolean =>
250
+ origin === undefined || (typeof origin === 'string' && (origin === '' || allowed(origin)));
251
+ return {
252
+ cors: {
253
+ origin: (origin, callback) => callback(null, admits(origin)),
254
+ credentials: options.allowCredentials ?? true,
255
+ methods: ['GET', 'POST'],
256
+ },
257
+ allowRequest: (req, callback) => callback(null, admits(req.headers.origin)),
258
+ };
259
+ }
260
+
200
261
  /**
201
262
  * Create a strict Oxy CORS middleware. See module docs.
202
263
  *
@@ -215,7 +276,7 @@ export function createOxyCors(options: OxyCorsOptions = {}): RequestHandler {
215
276
  maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,
216
277
  } = options;
217
278
 
218
- const explicitOrigins = normalizeAppOrigins(appOrigins);
279
+ const allowed = createOxyOriginCheck(appOrigins);
219
280
  const methodsHeader = methods.join(', ');
220
281
  const allowedHeadersHeader = allowedHeaders.join(', ');
221
282
  const exposedHeadersHeader = exposedHeaders.join(', ');
@@ -237,7 +298,7 @@ export function createOxyCors(options: OxyCorsOptions = {}): RequestHandler {
237
298
  // Origin is present. Caching correctness: this response varies by Origin.
238
299
  res.setHeader('Vary', 'Origin');
239
300
 
240
- if (!matchesAllowedOrigin(explicitOrigins, origin)) {
301
+ if (!allowed(origin)) {
241
302
  // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
242
303
  // will block the cross-origin read. Preflights for denied origins get a
243
304
  // 204 with no CORS headers (the actual request then fails CORS).
@@ -4,14 +4,33 @@
4
4
  * This subpath export provides Express middleware and Node.js-specific
5
5
  * utilities that are not available in React Native or browser environments.
6
6
  *
7
+ * One `OxyServer` per process, authenticated as the backend's own workload
8
+ * (ADR 0026/0049 — no api key, no client secret):
9
+ *
7
10
  * @example
8
11
  * ```ts
9
- * import { OxyServer, createOxyAuthMiddleware, createOxyRateLimit } from '@oxy.so/core/server';
12
+ * import {
13
+ * OxyServer,
14
+ * createOxyAuthMiddleware,
15
+ * createOxyCors,
16
+ * createOxyRateLimit,
17
+ * createOxySecurityHeaders,
18
+ * oxyTrustProxy,
19
+ * } from '@oxy.so/core/server';
10
20
  *
11
- * const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', serviceAuth: { apiKey, apiSecret } });
21
+ * export const oxy = new OxyServer({
22
+ * baseURL: process.env.OXY_API_URL ?? 'https://api.oxy.so',
23
+ * applicationId: process.env.OXY_APPLICATION_ID,
24
+ * serviceIdentity: 'when-anonymous',
25
+ * });
12
26
  *
27
+ * app.set('trust proxy', oxyTrustProxy);
28
+ * app.use(createOxySecurityHeaders(), createOxyCors({ appOrigins }));
13
29
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
14
30
  * app.use('/api', createOxyAuthMiddleware(oxy));
31
+ *
32
+ * // As the person behind the request, never on the shared client:
33
+ * const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
15
34
  * ```
16
35
  */
17
36
 
@@ -24,6 +43,17 @@ export {
24
43
  DEFAULT_SERVICE_TOKEN_AUDIENCE,
25
44
  } from './OxyServer';
26
45
  export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
46
+ // The requesting person: an isolated client bound to their bearer, and what
47
+ // they may do in an account or over an application (with typed outcomes).
48
+ export { OxyUserClient } from './userClient';
49
+ export type { OxyUserSource } from './userClient';
50
+ export { OxyAccessError, accessErrorStatus } from './callerAuthority';
51
+ export type { OxyAccessDenyStatus, OxyAccessErrorCode, OxyCallerRequest } from './callerAuthority';
52
+ export type {
53
+ CallerAccessOptions,
54
+ OxyAccountAccess,
55
+ OxyApplicationAccess,
56
+ } from './callerAccess';
27
57
  export {
28
58
  ServerAccountsApi,
29
59
  ServerAppsApi,
@@ -53,6 +83,7 @@ export type {
53
83
  CapabilityTicketGrant,
54
84
  CapabilityTicketIntrospection,
55
85
  CreateExecutionAuthorizationInput,
86
+ ServiceCallOptions,
56
87
  ServiceCapabilityCatalog,
57
88
  ServiceUploadBody,
58
89
  ServiceUploadDescription,
@@ -77,6 +108,7 @@ export type {
77
108
  OxyAccountEventFeedItem,
78
109
  OxyAccountEventFeedPage,
79
110
  OxyMiddleware,
111
+ RequireAccessOptions,
80
112
  ServiceActingAsVerification,
81
113
  ServiceApp,
82
114
  VerifyAccountEventOptions,
@@ -186,8 +218,8 @@ export type {
186
218
  } from './largeUpload';
187
219
 
188
220
  // Strict CORS allowlist (Oxy apex family + explicit app origins).
189
- export { createOxyCors } from './cors';
190
- export type { OxyCorsOptions } from './cors';
221
+ export { createOxyCors, createOxyOriginCheck, createOxySocketCors } from './cors';
222
+ export type { OxyCorsOptions, OxySocketCorsOptions } from './cors';
191
223
 
192
224
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
193
225
  // Oxy API/CDN origins) with additive, per-app extensions.
@@ -224,6 +256,8 @@ export {
224
256
  verifyCapabilityTicket,
225
257
  } from './capabilityTicket';
226
258
  export type {
259
+ AsyncCapabilityTicketVerificationOptions,
260
+ CapabilityTicketKeyResolver,
227
261
  CapabilityTicketMiddlewareOptions,
228
262
  CapabilityTicketRequest,
229
263
  CapabilityTicketSigningOptions,
@@ -4,12 +4,17 @@ import {
4
4
  type CapabilityTicketClaims,
5
5
  } from '@oxy.so/contracts';
6
6
  import {
7
- verifyCapabilityTicket,
8
- type CapabilityTicketVerificationOptions,
7
+ resolveCapabilityTicketKey,
8
+ type AsyncCapabilityTicketVerificationOptions,
9
9
  } from './capabilityTicket';
10
10
  import type { CapabilityTicketIntrospection } from './namespaces';
11
11
 
12
- export interface LiveCapabilityTicketVerifierOptions extends CapabilityTicketVerificationOptions {
12
+ /**
13
+ * `resolvePublicKey` may be asynchronous — pass `createOxyJwksKeyResolver({ jwksUrl })`
14
+ * as it is; the ticket's `kid` is looked up once per verification.
15
+ */
16
+ export interface LiveCapabilityTicketVerifierOptions
17
+ extends AsyncCapabilityTicketVerificationOptions {
13
18
  issuer: string;
14
19
  introspect: (
15
20
  ticket: string,
@@ -32,7 +37,9 @@ export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicket
32
37
  input: { signal?: AbortSignal } = {},
33
38
  ): Promise<CapabilityTicketClaims> => {
34
39
  input.signal?.throwIfAborted();
35
- const signed = verifyCapabilityTicket(ticket, options);
40
+ const verifier = await resolveCapabilityTicketKey(ticket, options);
41
+ input.signal?.throwIfAborted();
42
+ const signed = verifier.check();
36
43
  if (!signed.catalog) throw new Error('Internal MCP requires a signed catalogue binding');
37
44
  const controller = new AbortController();
38
45
  const abort = () => controller.abort(input.signal?.reason);
@@ -59,7 +66,7 @@ export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicket
59
66
  if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
60
67
  throw new Error('Capability introspection claims mismatch');
61
68
  // An authority response delayed past expiry cannot extend the signed grant.
62
- return verifyCapabilityTicket(ticket, options);
69
+ return verifier.check();
63
70
  } finally {
64
71
  clearTimeout(timer);
65
72
  input.signal?.removeEventListener('abort', abort);