@oxy.so/core 7.16.0 → 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 +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 +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,145 @@
1
+ /**
2
+ * The person behind a request, in an Oxy account and over an Oxy application:
3
+ * `OxyServer.accountAccessFor` and `OxyServer.applicationAccessFor`.
4
+ *
5
+ * Both read Oxy with the request's OWN verified bearer (an `OxyUserClient`),
6
+ * so Oxy resolves the HUMAN operator from the session — a person acting as
7
+ * their organization is answered for themselves — with inheritance, per-member
8
+ * grants and revokes applied. Nothing else the client sends is consulted.
9
+ *
10
+ * ```ts
11
+ * // Can the caller update this store (an Oxy account) in my app?
12
+ * const access = await oxy.accountAccessFor(req, storeAccountId, { fresh: true });
13
+ * if (!access.has('account:update')) throw new OxyAccessError('ACCESS_DENIED', '…');
14
+ *
15
+ * // Can the caller manage webhooks of application A (developer RBAC)?
16
+ * const app = await oxy.applicationAccessFor(req, applicationId);
17
+ * if (!app.has('webhooks:update')) …
18
+ * ```
19
+ *
20
+ * Which reader answers which question is in `./callerAuthority`; the short
21
+ * version: `appPermissionsFor` is about THIS backend's own declared operator
22
+ * keys, `applicationAccessFor` about any application through the account that
23
+ * owns it, `accountAccessFor` about an account. For work with no person
24
+ * present (a background import), `oxy.accounts.accessFor(accountId, userId)`
25
+ * asks with the service's own token instead.
26
+ *
27
+ * Node only; reachable solely from `@oxy.so/core/server`.
28
+ */
29
+ import { accountAccessGrantSchema, } from '@oxy.so/contracts';
30
+ import { accessFailure, CallerAuthorityCache, OxyAccessError, permissionSet, readCallerSession, } from './callerAuthority.js';
31
+ function callerSession(req) {
32
+ const caller = readCallerSession(req);
33
+ if (caller.ok)
34
+ return caller.session;
35
+ throw new OxyAccessError('ACCESS_SESSION_REQUIRED', caller.reason === 'service'
36
+ ? 'This request is authenticated by a service token; account and application access belong to a person in a session'
37
+ : 'No verified user session on this request; mount middleware.auth() first');
38
+ }
39
+ function requireId(id, what) {
40
+ if (typeof id !== 'string' || id.trim() === '') {
41
+ throw new TypeError(`${what} must be a non-empty string`);
42
+ }
43
+ return id;
44
+ }
45
+ function record(value) {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
47
+ ? value
48
+ : null;
49
+ }
50
+ function stringArray(value) {
51
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
52
+ ? value
53
+ : null;
54
+ }
55
+ const RELATIONSHIPS = ['self', 'owner', 'member'];
56
+ /** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
57
+ export function parseAccountAccess(accountId, answer) {
58
+ const node = record(answer);
59
+ if (!node || node.accountId !== accountId)
60
+ return null;
61
+ const relationship = node.relationship;
62
+ if (!RELATIONSHIPS.includes(relationship) || typeof node.kind !== 'string')
63
+ return null;
64
+ const grant = accountAccessGrantSchema.safeParse(node.callerAccess);
65
+ if (!grant.success)
66
+ return null;
67
+ return Object.freeze({
68
+ accountId,
69
+ kind: node.kind,
70
+ relationship,
71
+ role: grant.data.role,
72
+ source: grant.data.source,
73
+ ...permissionSet(grant.data.permissions),
74
+ });
75
+ }
76
+ /** `GET /applications/:id` → the caller's access over it, or `null` for a malformed answer. */
77
+ export function parseApplicationAccess(applicationId, answer) {
78
+ const application = record(answer);
79
+ if (!application || application._id !== applicationId)
80
+ return null;
81
+ const ownerAccountId = application.ownerAccountId;
82
+ const membership = record(application.callerMembership);
83
+ const permissions = stringArray(membership?.permissions);
84
+ if (typeof ownerAccountId !== 'string' ||
85
+ ownerAccountId === '' ||
86
+ typeof application.isOfficial !== 'boolean' ||
87
+ !membership ||
88
+ typeof membership.role !== 'string' ||
89
+ membership.role === '' ||
90
+ membership.ownerAccountId !== ownerAccountId ||
91
+ !permissions) {
92
+ return null;
93
+ }
94
+ return Object.freeze({
95
+ applicationId,
96
+ ownerAccountId,
97
+ official: application.isOfficial,
98
+ role: membership.role,
99
+ ...permissionSet(permissions),
100
+ });
101
+ }
102
+ /** The caches and lookups behind `accountAccessFor` / `applicationAccessFor`. One per server. */
103
+ export class OxyCallerAccessResolver {
104
+ constructor(host) {
105
+ this.host = host;
106
+ this.accounts = new CallerAuthorityCache(host.ttlMs);
107
+ this.applications = new CallerAuthorityCache(host.ttlMs);
108
+ }
109
+ async account(req, accountId, options = {}) {
110
+ const id = requireId(accountId, 'accountId');
111
+ const session = callerSession(req);
112
+ return this.accounts.read([id], session, options.fresh === true, async () => {
113
+ const answer = await this.host
114
+ .readAccount(id, session.accessToken)
115
+ .catch((error) => {
116
+ throw accessFailure(error, `account ${id}`);
117
+ });
118
+ const access = parseAccountAccess(id, answer);
119
+ if (!access) {
120
+ throw new OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered account ${id} without the caller's access`);
121
+ }
122
+ return access;
123
+ });
124
+ }
125
+ async application(req, applicationId, options = {}) {
126
+ const id = requireId(applicationId, 'applicationId');
127
+ const session = callerSession(req);
128
+ return this.applications.read([id], session, options.fresh === true, async () => {
129
+ const answer = await this.host
130
+ .readApplication(id, session.accessToken)
131
+ .catch((error) => {
132
+ throw accessFailure(error, `application ${id}`);
133
+ });
134
+ const access = parseApplicationAccess(id, answer);
135
+ if (!access) {
136
+ throw new OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered application ${id} without the caller's access`);
137
+ }
138
+ return access;
139
+ });
140
+ }
141
+ invalidate(filter = {}) {
142
+ this.accounts.invalidate(filter);
143
+ this.applications.invalidate(filter);
144
+ }
145
+ }
@@ -0,0 +1,194 @@
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
+ /** Entries kept per reader; the oldest goes first. */
26
+ const CALLER_AUTHORITY_CACHE_MAX = 1000;
27
+ /**
28
+ * The person behind `req`, or why there is none: `service` for a request
29
+ * authenticated by a service token (delegated or not — its bearer is the
30
+ * service's, never the person's), `missing` when `middleware.auth()` resolved
31
+ * no user session.
32
+ */
33
+ export function readCallerSession(req) {
34
+ if (req.serviceApp)
35
+ return { ok: false, reason: 'service' };
36
+ const { userId, sessionId, accessToken } = req;
37
+ if (typeof userId !== 'string' ||
38
+ userId.length === 0 ||
39
+ typeof sessionId !== 'string' ||
40
+ sessionId.length === 0 ||
41
+ typeof accessToken !== 'string' ||
42
+ !isBearerToken(accessToken)) {
43
+ return { ok: false, reason: 'missing' };
44
+ }
45
+ return { ok: true, session: { userId, sessionId, accessToken } };
46
+ }
47
+ /** A bearer that can be put in an `Authorization` header unchanged. */
48
+ export function isBearerToken(token) {
49
+ return token.length > 0 && !/\s/.test(token);
50
+ }
51
+ /** The typed failure of `asUser`, `accountAccessFor`, `applicationAccessFor` and their middleware. */
52
+ export class OxyAccessError extends Error {
53
+ constructor(code, message, options = {}) {
54
+ super(message);
55
+ this.name = 'OxyAccessError';
56
+ this.code = code;
57
+ this.permission = options.permission;
58
+ this.cause = options.cause;
59
+ }
60
+ }
61
+ /**
62
+ * The status for an {@link OxyAccessError}: `deny` (default `403`) for a
63
+ * refusal; `401` for no session (`404` when `deny` is `404`, so a hidden
64
+ * surface stays hidden from the signed-out too); `503` when Oxy could not say.
65
+ */
66
+ export function accessErrorStatus(error, deny = 403) {
67
+ switch (error.code) {
68
+ case 'ACCESS_DENIED':
69
+ return deny;
70
+ case 'ACCESS_SESSION_REQUIRED':
71
+ return deny === 404 ? 404 : 401;
72
+ case 'ACCESS_UNAVAILABLE':
73
+ return 503;
74
+ }
75
+ }
76
+ /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
77
+ export function authorityErrorBody(status) {
78
+ switch (status) {
79
+ case 401:
80
+ return { error: { code: 'unauthorized', message: 'Authentication required' } };
81
+ case 403:
82
+ return { error: { code: 'forbidden', message: 'Not permitted' } };
83
+ case 404:
84
+ return { error: { code: 'not_found', message: 'Not found' } };
85
+ case 503:
86
+ return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
87
+ default:
88
+ return { error: { code: 'internal_error', message: 'Internal error' } };
89
+ }
90
+ }
91
+ /** The HTTP status an `OxyApiError` (or anything shaped like one) carries. */
92
+ export function errorStatus(error) {
93
+ const status = error?.status;
94
+ return typeof status === 'number' ? status : undefined;
95
+ }
96
+ /**
97
+ * Map a failed caller-bearer read of Oxy to its outcome: 401 → session
98
+ * required, 403/404 → denied, everything else → unavailable.
99
+ */
100
+ export function accessFailure(error, what) {
101
+ const status = errorStatus(error);
102
+ if (status === 401) {
103
+ return new OxyAccessError('ACCESS_SESSION_REQUIRED', `Oxy refused the session reading ${what}`, {
104
+ cause: error,
105
+ });
106
+ }
107
+ if (status === 403 || status === 404) {
108
+ return new OxyAccessError('ACCESS_DENIED', `Oxy refused ${what} to this session`, {
109
+ cause: error,
110
+ });
111
+ }
112
+ return new OxyAccessError('ACCESS_UNAVAILABLE', `Could not read ${what} from Oxy`, {
113
+ cause: error,
114
+ });
115
+ }
116
+ /**
117
+ * The positive-only answer cache one caller-authority reader keeps, keyed by
118
+ * (resource, session, person). Concurrent identical reads share one lookup; a
119
+ * `fresh` read skips the cache and refreshes it; a slower, OLDER answer never
120
+ * overwrites a newer one (the newer may be the fresh read that saw a
121
+ * revocation); a failure is never stored.
122
+ */
123
+ export class CallerAuthorityCache {
124
+ /**
125
+ * @param ttlMs How long an answer is reused; `0` disables the cache.
126
+ * @param isAbout Whether a cached answer concerns `userId` beyond the
127
+ * session's own user (an application-permissions answer names the
128
+ * resolved operator), for {@link invalidate}.
129
+ */
130
+ constructor(ttlMs, isAbout = () => false) {
131
+ this.ttlMs = ttlMs;
132
+ this.isAbout = isAbout;
133
+ this.entries = new Map();
134
+ this.pending = new Map();
135
+ }
136
+ async read(resourceKey, session, fresh, lookup) {
137
+ const key = JSON.stringify([...resourceKey, session.sessionId, session.userId]);
138
+ const ttl = this.ttlMs;
139
+ if (!fresh && ttl > 0) {
140
+ const cached = this.entries.get(key);
141
+ if (cached && cached.expiresAt > Date.now())
142
+ return cached.value;
143
+ const inFlight = this.pending.get(key);
144
+ if (inFlight)
145
+ return inFlight;
146
+ }
147
+ const startedAt = Date.now();
148
+ const answer = lookup().then((value) => {
149
+ if (ttl > 0)
150
+ this.remember(key, session.userId, value, startedAt, ttl);
151
+ return value;
152
+ });
153
+ if (fresh || ttl <= 0)
154
+ return answer;
155
+ this.pending.set(key, answer);
156
+ try {
157
+ return await answer;
158
+ }
159
+ finally {
160
+ if (this.pending.get(key) === answer)
161
+ this.pending.delete(key);
162
+ }
163
+ }
164
+ /** Forget every answer, or those about one person. */
165
+ invalidate(filter = {}) {
166
+ const { userId } = filter;
167
+ if (!userId) {
168
+ this.entries.clear();
169
+ return;
170
+ }
171
+ for (const [key, entry] of this.entries) {
172
+ if (entry.userId === userId || this.isAbout(entry.value, userId))
173
+ this.entries.delete(key);
174
+ }
175
+ }
176
+ remember(key, userId, value, startedAt, ttl) {
177
+ const existing = this.entries.get(key);
178
+ if (existing && existing.startedAt > startedAt)
179
+ return;
180
+ this.entries.delete(key);
181
+ this.entries.set(key, { value, userId, startedAt, expiresAt: startedAt + ttl });
182
+ if (this.entries.size > CALLER_AUTHORITY_CACHE_MAX) {
183
+ const oldest = this.entries.keys().next().value;
184
+ if (oldest !== undefined)
185
+ this.entries.delete(oldest);
186
+ }
187
+ }
188
+ }
189
+ /** A frozen, sorted permission set with `has`. */
190
+ export function permissionSet(permissions) {
191
+ const sorted = Object.freeze([...new Set(permissions)].sort());
192
+ const held = new Set(sorted);
193
+ return { permissions: sorted, has: (permission) => held.has(permission) };
194
+ }
@@ -41,7 +41,7 @@ export function issueCapabilityTicket(claims, options) {
41
41
  const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
42
42
  return `${signingInput}.${base64UrlEncode(signBytes(null, Buffer.from(signingInput), options.privateKey))}`;
43
43
  }
44
- export function verifyCapabilityTicket(token, options) {
44
+ function parseCapabilityTicket(token) {
45
45
  const segments = token.split('.');
46
46
  if (segments.length !== 3) {
47
47
  throw new CapabilityTicketError('malformed', 'Capability ticket must have three segments');
@@ -62,21 +62,29 @@ export function verifyCapabilityTicket(token, options) {
62
62
  headerRecord.kid.length === 0) {
63
63
  throw new CapabilityTicketError('malformed', 'Capability ticket header is not supported');
64
64
  }
65
- const publicKey = options.resolvePublicKey(headerRecord.kid);
65
+ return {
66
+ keyId: headerRecord.kid,
67
+ signingInput: `${encodedHeader}.${encodedPayload}`,
68
+ encodedPayload,
69
+ encodedSignature,
70
+ };
71
+ }
72
+ /** Signature and claims of a parsed ticket against the key its `kid` resolved to. */
73
+ function checkCapabilityTicket(ticket, publicKey, options) {
66
74
  if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
67
75
  throw new CapabilityTicketError('unknown_key', 'Capability ticket signing key is not trusted');
68
76
  }
69
77
  let providedSignature;
70
78
  try {
71
- providedSignature = Buffer.from(encodedSignature, 'base64url');
79
+ providedSignature = Buffer.from(ticket.encodedSignature, 'base64url');
72
80
  }
73
81
  catch {
74
82
  throw new CapabilityTicketError('malformed', 'Capability ticket signature is malformed');
75
83
  }
76
- if (!verifyBytes(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), publicKey, providedSignature)) {
84
+ if (!verifyBytes(null, Buffer.from(ticket.signingInput), publicKey, providedSignature)) {
77
85
  throw new CapabilityTicketError('invalid_signature', 'Capability ticket signature is invalid');
78
86
  }
79
- const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(encodedPayload));
87
+ const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(ticket.encodedPayload));
80
88
  if (!parsed.success) {
81
89
  throw new CapabilityTicketError('invalid_claims', 'Capability ticket claims are invalid');
82
90
  }
@@ -99,6 +107,30 @@ export function verifyCapabilityTicket(token, options) {
99
107
  }
100
108
  return claims;
101
109
  }
110
+ export function verifyCapabilityTicket(token, options) {
111
+ const ticket = parseCapabilityTicket(token);
112
+ return checkCapabilityTicket(ticket, options.resolvePublicKey(ticket.keyId), options);
113
+ }
114
+ /**
115
+ * A verifier for one ticket whose key lookup may be asynchronous: the key is
116
+ * resolved once, and the returned `check` re-verifies signature and claims
117
+ * against it (the live verifier checks again after introspection, so a slow
118
+ * authority answer cannot outlive the ticket).
119
+ *
120
+ * @internal Shared by the middleware and `createLiveCapabilityTicketVerifier`.
121
+ */
122
+ export async function resolveCapabilityTicketKey(token, options) {
123
+ const ticket = parseCapabilityTicket(token);
124
+ let publicKey;
125
+ try {
126
+ publicKey = await options.resolvePublicKey(ticket.keyId);
127
+ }
128
+ catch {
129
+ // A key set that cannot be read trusts no key; never a different error class.
130
+ publicKey = undefined;
131
+ }
132
+ return { check: () => checkCapabilityTicket(ticket, publicKey, options) };
133
+ }
102
134
  export function readCapabilityAuthorization(authorization) {
103
135
  if (!authorization)
104
136
  return null;
@@ -121,7 +153,7 @@ export function createCapabilityTicketMiddleware(options) {
121
153
  }
122
154
  let claims;
123
155
  try {
124
- claims = verifyCapabilityTicket(token, options);
156
+ claims = (await resolveCapabilityTicketKey(token, options)).check();
125
157
  }
126
158
  catch (error) {
127
159
  const code = error instanceof CapabilityTicketError ? error.code : 'invalid_claims';
@@ -158,6 +158,43 @@ export function matchesAllowedOrigin(explicit, origin) {
158
158
  return true;
159
159
  return isOxyFamilyOrigin(normalized);
160
160
  }
161
+ /**
162
+ * The origin predicate `createOxyCors` applies, for any other transport that
163
+ * must admit the same browsers: the built-in HTTPS Oxy apex family plus the
164
+ * exact `appOrigins`, never an opaque origin (`exp://`, `file:`, …).
165
+ *
166
+ * ```ts
167
+ * const allowed = createOxyOriginCheck(['https://app.example.com']);
168
+ * allowed('https://mention.oxy.so'); // true
169
+ * ```
170
+ */
171
+ export function createOxyOriginCheck(appOrigins = []) {
172
+ const explicit = normalizeAppOrigins(appOrigins);
173
+ return (origin) => matchesAllowedOrigin(explicit, origin);
174
+ }
175
+ /**
176
+ * The Socket.IO server options that admit exactly the origins `createOxyCors`
177
+ * admits — spread them into `new Server(httpServer, { ...createOxySocketCors(…) })`.
178
+ *
179
+ * Two halves, because Socket.IO has two transports: `cors` answers the
180
+ * long-polling requests (which browsers check), and `allowRequest` refuses a
181
+ * handshake from any other browser origin, which is the only check a WebSocket
182
+ * upgrade gets — browsers apply no CORS to it. A request with no `Origin`
183
+ * header (a native app, a server) is not a browser cross-origin request and is
184
+ * admitted; authenticate it with `oxy.middleware.socket()` as every socket.
185
+ */
186
+ export function createOxySocketCors(options = {}) {
187
+ const allowed = createOxyOriginCheck(options.appOrigins);
188
+ const admits = (origin) => origin === undefined || (typeof origin === 'string' && (origin === '' || allowed(origin)));
189
+ return {
190
+ cors: {
191
+ origin: (origin, callback) => callback(null, admits(origin)),
192
+ credentials: options.allowCredentials ?? true,
193
+ methods: ['GET', 'POST'],
194
+ },
195
+ allowRequest: (req, callback) => callback(null, admits(req.headers.origin)),
196
+ };
197
+ }
161
198
  /**
162
199
  * Create a strict Oxy CORS middleware. See module docs.
163
200
  *
@@ -168,7 +205,7 @@ export function matchesAllowedOrigin(explicit, origin) {
168
205
  */
169
206
  export function createOxyCors(options = {}) {
170
207
  const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
171
- const explicitOrigins = normalizeAppOrigins(appOrigins);
208
+ const allowed = createOxyOriginCheck(appOrigins);
172
209
  const methodsHeader = methods.join(', ');
173
210
  const allowedHeadersHeader = allowedHeaders.join(', ');
174
211
  const exposedHeadersHeader = exposedHeaders.join(', ');
@@ -186,7 +223,7 @@ export function createOxyCors(options = {}) {
186
223
  }
187
224
  // Origin is present. Caching correctness: this response varies by Origin.
188
225
  res.setHeader('Vary', 'Origin');
189
- if (!matchesAllowedOrigin(explicitOrigins, origin)) {
226
+ if (!allowed(origin)) {
190
227
  // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
191
228
  // will block the cross-origin read. Preflights for denied origins get a
192
229
  // 204 with no CORS headers (the actual request then fails CORS).
@@ -4,19 +4,42 @@
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
  // The backend client: OxyServices plus the service-token lane, middleware,
18
37
  // account events and the service-only namespace methods.
19
38
  export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer.js';
39
+ // The requesting person: an isolated client bound to their bearer, and what
40
+ // they may do in an account or over an application (with typed outcomes).
41
+ export { OxyUserClient } from './userClient.js';
42
+ export { OxyAccessError, accessErrorStatus } from './callerAuthority.js';
20
43
  export { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
21
44
  export { ServerSafetyApi } from './safety.js';
22
45
  export { ServerSessionApi } from './session.js';
@@ -41,7 +64,7 @@ export { FILE_EVENT_DEFAULT_TOLERANCE_SECONDS, FILE_EVENT_SIGNATURE_HEADER, FILE
41
64
  export { STREAM_KEY_AUTHORIZATION_EVENT_TYPE, StreamKeyAuthorizationVerificationError, verifyStreamKeyAuthorization, } from './streamKeyAuthorization.js';
42
65
  export { LargeUploadFailedError } from './largeUpload.js';
43
66
  // Strict CORS allowlist (Oxy apex family + explicit app origins).
44
- export { createOxyCors } from './cors.js';
67
+ export { createOxyCors, createOxyOriginCheck, createOxySocketCors } from './cors.js';
45
68
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
46
69
  // Oxy API/CDN origins) with additive, per-app extensions.
47
70
  //
@@ -1,5 +1,5 @@
1
1
  import { canonicalCapabilityJson, capabilityTicketClaimsSchema, } from '@oxy.so/contracts';
2
- import { verifyCapabilityTicket, } from './capabilityTicket.js';
2
+ import { resolveCapabilityTicketKey, } from './capabilityTicket.js';
3
3
  /** Local signature + live receiving-service introspection; no cache or retry. */
4
4
  export function createLiveCapabilityTicketVerifier(options) {
5
5
  if (typeof options.issuer !== 'string' || options.issuer.trim() === '')
@@ -10,7 +10,9 @@ export function createLiveCapabilityTicketVerifier(options) {
10
10
  }
11
11
  return async (ticket, input = {}) => {
12
12
  input.signal?.throwIfAborted();
13
- const signed = verifyCapabilityTicket(ticket, options);
13
+ const verifier = await resolveCapabilityTicketKey(ticket, options);
14
+ input.signal?.throwIfAborted();
15
+ const signed = verifier.check();
14
16
  if (!signed.catalog)
15
17
  throw new Error('Internal MCP requires a signed catalogue binding');
16
18
  const controller = new AbortController();
@@ -36,7 +38,7 @@ export function createLiveCapabilityTicketVerifier(options) {
36
38
  if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
37
39
  throw new Error('Capability introspection claims mismatch');
38
40
  // An authority response delayed past expiry cannot extend the signed grant.
39
- return verifyCapabilityTicket(ticket, options);
41
+ return verifier.check();
40
42
  }
41
43
  finally {
42
44
  clearTimeout(timer);