@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
@@ -29,6 +29,16 @@
29
29
  * response; without one, a server that sends its headers and stalls holds
30
30
  * any other reader open.
31
31
  *
32
+ * NOT FOR OXY'S OWN HOSTS
33
+ * -----------------------
34
+ * `safeFetch` is for UNTRUSTED or caller-influenced URLs only. Never use it to
35
+ * reach a first-party host (`api.oxy.so`, any `*.oxy.so`, an Oxy product's
36
+ * API): inside the VPC those names resolve to the internal load balancer
37
+ * (split-horizon DNS), a private address this module refuses by design. Call
38
+ * Oxy through `OxyServer` — its namespaces, `serviceRequest`, or
39
+ * `createServiceClient({ baseURL, audience }).fetch` for another Oxy product.
40
+ * A refusal of such a host says so in its message.
41
+ *
32
42
  * Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
33
43
  * exported solely from `@oxy.so/core/server`. It MUST NOT be reachable from the
34
44
  * browser `@oxy.so/core` entry.
@@ -46,6 +56,7 @@ const node_https_1 = __importDefault(require("node:https"));
46
56
  const promises_1 = require("node:dns/promises");
47
57
  const node_net_1 = require("node:net");
48
58
  const node_url_1 = require("node:url");
59
+ const officialOrigins_1 = require("../utils/officialOrigins");
49
60
  const deadline_1 = require("./deadline");
50
61
  const ipCidr_1 = require("./ipCidr");
51
62
  /** Maximum accepted length of an input URL (DoS guard). */
@@ -255,6 +266,21 @@ async function assertSafePublicUrl(rawUrl, options = {}) {
255
266
  family: chosen.family === 4 ? 4 : 6,
256
267
  };
257
268
  }
269
+ /** What a refusal of a first-party Oxy host adds to its reason. */
270
+ const FIRST_PARTY_HOST_HINT = 'first-party Oxy host: inside the VPC it resolves to a private address, which safeFetch refuses by design; call it through OxyServer (its namespaces, serviceRequest, or createServiceClient().fetch), not safeFetch';
271
+ /** `reason`, plus the first-party hint when `rawUrl` names an official Oxy host. */
272
+ function rejectionReason(rawUrl, reason) {
273
+ let origin;
274
+ try {
275
+ origin = new node_url_1.URL(rawUrl).origin;
276
+ }
277
+ catch {
278
+ return reason;
279
+ }
280
+ return (0, officialOrigins_1.isOfficialWebOrigin)(origin) && !(0, officialOrigins_1.isLoopbackOrigin)(origin)
281
+ ? `${reason} (${FIRST_PARTY_HOST_HINT})`
282
+ : reason;
283
+ }
258
284
  /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
259
285
  class SsrfRejection extends Error {
260
286
  constructor(reason) {
@@ -353,7 +379,9 @@ async function safeFetch(rawUrl, options = {}) {
353
379
  signal,
354
380
  });
355
381
  if (!guard.ok) {
356
- throw guard.incomplete ? new UpstreamError(guard.reason) : new SsrfRejection(guard.reason);
382
+ throw guard.incomplete
383
+ ? new UpstreamError(guard.reason)
384
+ : new SsrfRejection(rejectionReason(currentUrl, guard.reason));
357
385
  }
358
386
  const target = new node_url_1.URL(currentUrl);
359
387
  const requestOptions = buildRequestOptions(target, guard.ip, guard.family, method, baseHeaders, signal);
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * Calling Oxy AS the person behind a request.
4
+ *
5
+ * A backend's `OxyServer` is a process singleton authenticated as the backend
6
+ * itself; a user's bearer must never be planted on it (`setTokens`,
7
+ * `session.setAccessToken`), because every concurrent request would then speak
8
+ * as that user. A read that must be answered for the person — their own
9
+ * account graph, an application they manage, their profile — goes through an
10
+ * {@link OxyUserClient}: every namespace of `OxyServices`, bound to that one
11
+ * bearer, sharing no mutable state with the server.
12
+ *
13
+ * ```ts
14
+ * // Express, after oxy.middleware.auth():
15
+ * const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
16
+ * ```
17
+ *
18
+ * What makes it safe to create one per request:
19
+ *
20
+ * - **No response cache.** The client is built with `enableCache: false`, so
21
+ * one person's answers are never served to anyone, including the same
22
+ * person's next request, and nothing is registered with the module-wide
23
+ * cache sweep.
24
+ * - **Exactly this bearer, every time.** Every request carries it as an
25
+ * explicit `Authorization` header: never refreshed (that is the user's own
26
+ * client's job), never dropped for an expired or opaque token, never
27
+ * replaced by the server's service token. Oxy answers a revoked or expired
28
+ * bearer 401, which the access readers map to `ACCESS_SESSION_REQUIRED`.
29
+ * - **Deterministic end.** `withUser` disposes the client when the callback
30
+ * settles, so a reference that escapes the callback holds no bearer. A
31
+ * client from `asUser` that is never disposed holds no global resource
32
+ * either; it is collected with its last reference.
33
+ *
34
+ * Node only; reachable solely from `@oxy.so/core/server`.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.OxyUserClient = void 0;
38
+ exports.userClientConfig = userClientConfig;
39
+ exports.userBearerOf = userBearerOf;
40
+ const OxyServices_1 = require("../OxyServices");
41
+ const callerAuthority_1 = require("./callerAuthority");
42
+ /** The transport settings a user client inherits from its server; never auth, cache or app identity. */
43
+ const INHERITED_TRANSPORT_KEYS = [
44
+ 'baseURL',
45
+ 'cloudURL',
46
+ 'requestTimeout',
47
+ 'enableRetry',
48
+ 'maxRetries',
49
+ 'retryDelay',
50
+ 'maxConcurrentRequests',
51
+ 'requestQueueSize',
52
+ 'enableLogging',
53
+ 'logLevel',
54
+ 'onRequestStart',
55
+ 'onRequestEnd',
56
+ 'onRequestError',
57
+ ];
58
+ /** The transport half of a server config, for a user client. */
59
+ function userClientConfig(config) {
60
+ const transport = {};
61
+ for (const key of INHERITED_TRANSPORT_KEYS) {
62
+ if (config[key] !== undefined)
63
+ transport[key] = config[key];
64
+ }
65
+ return { ...transport, enableCache: false };
66
+ }
67
+ /**
68
+ * The user bearer `source` carries. Throws `OxyAccessError`
69
+ * (`ACCESS_SESSION_REQUIRED`) for a request with no user session — anonymous,
70
+ * or authenticated by a SERVICE token, whose bearer is the calling service's
71
+ * and never a person's.
72
+ */
73
+ function userBearerOf(source) {
74
+ if (typeof source === 'string') {
75
+ if (!(0, callerAuthority_1.isBearerToken)(source)) {
76
+ throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'A user client needs a bearer token');
77
+ }
78
+ return source;
79
+ }
80
+ if (source.serviceApp) {
81
+ throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'This request is authenticated by a service token; a user client needs a person’s session');
82
+ }
83
+ const token = source.accessToken;
84
+ if (typeof token !== 'string' || !(0, callerAuthority_1.isBearerToken)(token)) {
85
+ throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
86
+ }
87
+ return token;
88
+ }
89
+ /**
90
+ * Every `OxyServices` namespace, authenticated as one person. Create it with
91
+ * `OxyServer.withUser` (disposed for you) or `OxyServer.asUser` (dispose it
92
+ * yourself); the constructor is not part of the public contract.
93
+ */
94
+ class OxyUserClient extends OxyServices_1.OxyServices {
95
+ /** @internal Use `OxyServer.asUser` / `OxyServer.withUser`. */
96
+ constructor(config, accessToken) {
97
+ super(config);
98
+ this.bearer = accessToken;
99
+ // For the namespaces that read the session's identity (`session.userId`).
100
+ this.http.setTokens(accessToken);
101
+ }
102
+ /**
103
+ * Every namespace call goes through here: it carries this client's bearer
104
+ * explicitly, which also keeps the response out of any cache and out of
105
+ * request de-duplication. Rejects once the client is disposed.
106
+ */
107
+ async request(method, url, data, options = {}) {
108
+ const bearer = this.bearer;
109
+ if (!bearer)
110
+ throw new Error('This Oxy user client has been disposed');
111
+ // This client speaks as its person only: no caller-supplied credential,
112
+ // in any letter case, rides along.
113
+ const headers = {};
114
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
115
+ if (name.toLowerCase() !== 'authorization')
116
+ headers[name] = value;
117
+ }
118
+ headers.Authorization = `Bearer ${bearer}`;
119
+ return super.request(method, url, data, { ...options, headers });
120
+ }
121
+ /** Forget the bearer: every later call rejects. */
122
+ dispose() {
123
+ this.bearer = null;
124
+ super.dispose();
125
+ }
126
+ }
127
+ exports.OxyUserClient = OxyUserClient;
@@ -11,6 +11,7 @@ exports.createCache = createCache;
11
11
  exports.registerCacheForCleanup = registerCacheForCleanup;
12
12
  exports.unregisterCacheFromCleanup = unregisterCacheFromCleanup;
13
13
  exports.stopAllCleanupIntervals = stopAllCleanupIntervals;
14
+ exports.sweptCacheCount = sweptCacheCount;
14
15
  /**
15
16
  * TTL-based cache implementation
16
17
  *
@@ -48,6 +49,10 @@ class TTLCache {
48
49
  this.cache = new Map();
49
50
  this.hits = 0;
50
51
  this.misses = 0;
52
+ /** Registered for the shared expiry sweep (see {@link registerCacheForCleanup}). */
53
+ this.swept = false;
54
+ /** The sweep generation this cache registered in; {@link stopAllCleanupIntervals} ends it. */
55
+ this.sweptGeneration = -1;
51
56
  this.defaultTTL = defaultTTL;
52
57
  this.maxEntries = maxEntries;
53
58
  }
@@ -66,6 +71,7 @@ class TTLCache {
66
71
  if (now >= entry.expiresAt) {
67
72
  this.cache.delete(key);
68
73
  this.misses++;
74
+ this.syncSweep();
69
75
  return null;
70
76
  }
71
77
  // Re-insert so Map order is recency order and eviction drops the LRU.
@@ -97,8 +103,7 @@ class TTLCache {
97
103
  if (oldest !== undefined)
98
104
  this.cache.delete(oldest);
99
105
  }
100
- if (!cleanupInterval && activeCaches.has(this))
101
- updateCleanupInterval();
106
+ this.syncSweep();
102
107
  }
103
108
  /**
104
109
  * Delete a specific cache entry
@@ -107,10 +112,8 @@ class TTLCache {
107
112
  */
108
113
  delete(key) {
109
114
  const deleted = this.cache.delete(key);
110
- // Only the delete that EMPTIES this cache can let the timer stop; checking
111
- // on every delete made bulk invalidation O(deletes x caches).
112
- if (deleted && this.cache.size === 0 && activeCaches.has(this))
113
- updateCleanupInterval();
115
+ if (deleted)
116
+ this.syncSweep();
114
117
  return deleted;
115
118
  }
116
119
  /**
@@ -120,8 +123,7 @@ class TTLCache {
120
123
  this.cache.clear();
121
124
  this.hits = 0;
122
125
  this.misses = 0;
123
- if (activeCaches.has(this))
124
- updateCleanupInterval();
126
+ this.syncSweep();
125
127
  }
126
128
  /**
127
129
  * Delete every entry whose key matches, in ONE pass.
@@ -135,8 +137,8 @@ class TTLCache {
135
137
  removed++;
136
138
  }
137
139
  }
138
- if (removed > 0 && this.cache.size === 0 && activeCaches.has(this))
139
- updateCleanupInterval();
140
+ if (removed > 0)
141
+ this.syncSweep();
140
142
  return removed;
141
143
  }
142
144
  /**
@@ -150,6 +152,7 @@ class TTLCache {
150
152
  return false;
151
153
  if (Date.now() >= entry.expiresAt) {
152
154
  this.cache.delete(key);
155
+ this.syncSweep();
153
156
  return false;
154
157
  }
155
158
  return true;
@@ -169,6 +172,7 @@ class TTLCache {
169
172
  this.cache.delete(key);
170
173
  }
171
174
  }
175
+ this.syncSweep();
172
176
  return validKeys;
173
177
  }
174
178
  /**
@@ -184,8 +188,29 @@ class TTLCache {
184
188
  removed++;
185
189
  }
186
190
  }
191
+ if (removed > 0)
192
+ this.syncSweep();
187
193
  return removed;
188
194
  }
195
+ /**
196
+ * Start or stop being swept: a registered cache is held by the shared sweep
197
+ * ONLY while it has entries to expire. An empty cache is referenced by
198
+ * nothing global, so a client nobody disposed is collected with its cache.
199
+ */
200
+ setSwept(swept) {
201
+ this.swept = swept;
202
+ this.sweptGeneration = sweepGeneration;
203
+ this.syncSweep();
204
+ }
205
+ syncSweep() {
206
+ if (this.swept && this.sweptGeneration === sweepGeneration && this.cache.size > 0) {
207
+ sweptCaches.add(this);
208
+ }
209
+ else {
210
+ sweptCaches.delete(this);
211
+ }
212
+ updateCleanupInterval();
213
+ }
189
214
  /**
190
215
  * Get cache size (number of entries)
191
216
  */
@@ -225,23 +250,30 @@ function createCache(ttl = 5 * 60 * 1000) {
225
250
  return new TTLCache(ttl);
226
251
  }
227
252
  /**
228
- * Global cache cleanup interval (runs every minute)
229
- * This helps prevent memory leaks from expired cache entries
253
+ * The shared expiry sweep: one unref'd interval, running only while some
254
+ * registered cache holds entries.
255
+ *
256
+ * `sweptCaches` holds exactly the registered caches that are NON-EMPTY. A
257
+ * cache joins when it gains its first entry and leaves when it is emptied — by
258
+ * the sweep itself once its entries expire — so the module never pins a cache
259
+ * that has nothing to expire. That is what keeps a short-lived client that was
260
+ * never disposed (one per request, say) collectable: the most it can be held
261
+ * for is its entries' TTL.
230
262
  */
231
263
  let cleanupInterval = null;
232
- const activeCaches = new Set();
264
+ const sweptCaches = new Set();
265
+ /** Bumped by {@link stopAllCleanupIntervals}, which ends every registration. */
266
+ let sweepGeneration = 0;
233
267
  /**
234
268
  * Register a cache for automatic cleanup
235
269
  * @param cache Cache instance to register
236
270
  */
237
271
  function registerCacheForCleanup(cache) {
238
- activeCaches.add(cache);
239
- updateCleanupInterval();
272
+ cache.setSwept(true);
240
273
  }
241
274
  /** Empty registered caches need no timer; importing a client must stay inert. */
242
275
  function updateCleanupInterval() {
243
- const hasEntries = [...activeCaches].some((cache) => cache.size() > 0);
244
- if (!hasEntries) {
276
+ if (sweptCaches.size === 0) {
245
277
  if (cleanupInterval)
246
278
  clearInterval(cleanupInterval);
247
279
  cleanupInterval = null;
@@ -250,25 +282,29 @@ function updateCleanupInterval() {
250
282
  if (cleanupInterval)
251
283
  return;
252
284
  cleanupInterval = setInterval(() => {
253
- for (const cache of activeCaches)
285
+ // `cleanup()` removes an emptied cache from the set as it goes.
286
+ for (const cache of [...sweptCaches])
254
287
  cache.cleanup();
255
- updateCleanupInterval();
256
288
  }, 60000);
257
289
  cleanupInterval.unref?.();
258
290
  }
259
291
  /** Unregister a cache without changing its entries or TTL behavior. */
260
292
  function unregisterCacheFromCleanup(cache) {
261
- activeCaches.delete(cache);
262
- updateCleanupInterval();
293
+ cache.setSwept(false);
263
294
  }
264
295
  /**
265
296
  * Stop all cleanup intervals (useful for testing)
266
297
  * This will clear the interval and unregister all caches
267
298
  */
268
299
  function stopAllCleanupIntervals() {
300
+ sweepGeneration += 1;
301
+ sweptCaches.clear();
269
302
  if (cleanupInterval) {
270
303
  clearInterval(cleanupInterval);
271
304
  cleanupInterval = null;
272
305
  }
273
- activeCaches.clear();
306
+ }
307
+ /** How many caches the shared sweep currently holds (tests). @internal */
308
+ function sweptCacheCount() {
309
+ return sweptCaches.size;
274
310
  }
@@ -52,6 +52,8 @@ import { canAttestWorkloadIdentity, requestWorkloadServiceToken, } from './workl
52
52
  import { createOxyMiddleware, OXY_ACCOUNT_DELETED_EVENT_URI, OXY_JWT_ISSUER, OxyAccountEventError, parseJsonSegment, resolveServiceTokenPublicKey, } from './middleware.js';
53
53
  import { APP_PERMISSIONS_CACHE_TTL_MS, OxyAppPermissionsResolver, } from './appPermissions.js';
54
54
  import { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
55
+ import { OxyCallerAccessResolver, } from './callerAccess.js';
56
+ import { OxyUserClient, userBearerOf, userClientConfig } from './userClient.js';
55
57
  import { ServerSafetyApi } from './safety.js';
56
58
  import { SESSION_VALIDATION_CACHE_TTL_MS, ServerSessionApi } from './session.js';
57
59
  import { OxyServiceEventFeed } from './serviceEvents.js';
@@ -166,15 +168,24 @@ export class OxyServer extends OxyServices {
166
168
  defaultApplicationId: () => this.defaultApplicationId(),
167
169
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
168
170
  });
171
+ this.callerAccess = new OxyCallerAccessResolver({
172
+ ttlMs: ttl,
173
+ readAccount: (accountId, accessToken) => this.withUser(accessToken, (user) => user.accounts.get(accountId)),
174
+ readApplication: (applicationId, accessToken) => this.withUser(accessToken, (user) => user.apps.get(applicationId)),
175
+ });
176
+ const defaultAudience = config.expectedAudience?.trim() || undefined;
169
177
  this.middleware = createOxyMiddleware({
170
178
  get baseURL() {
171
179
  return server.baseURL;
172
180
  },
181
+ defaultAudience,
173
182
  jwksCache: this.jwksCache,
174
183
  validateSession: (sessionId, options) => this.session.validate(sessionId, options),
175
184
  verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
176
185
  introspectServiceToken: (token) => this.introspectServiceToken(token),
177
186
  appPermissionsFor: (req, options) => this.appPermissionsFor(req, options),
187
+ accountAccessFor: (req, accountId, options) => this.accountAccessFor(req, accountId, options),
188
+ applicationAccessFor: (req, applicationId, options) => this.applicationAccessFor(req, applicationId, options),
178
189
  });
179
190
  }
180
191
  /** A product SDK transport authenticated only by this process's workload. */
@@ -601,12 +612,83 @@ export class OxyServer extends OxyServices {
601
612
  return this.appPermissions.resolve(req, options);
602
613
  }
603
614
  /**
604
- * Drop cached application-permission answers — all of them, or one
605
- * person's (as the session user or the resolved operator). For a backend
606
- * that learns of a revocation out of band and will not wait out the TTL.
615
+ * Drop cached caller-authority answers — application permissions and
616
+ * account / application access — all of them, or one person's (as the
617
+ * session user or the resolved operator). For a backend that learns of a
618
+ * revocation out of band and will not wait out the TTL.
607
619
  */
608
620
  invalidateAppPermissions(filter = {}) {
609
621
  this.appPermissions.invalidate(filter);
622
+ this.callerAccess.invalidate(filter);
623
+ }
624
+ // ── The requesting person ────────────────────────────────────────────────
625
+ /**
626
+ * An isolated Oxy client authenticated as the person behind `source` — a
627
+ * request `middleware.auth()` resolved to a user session, or a bearer you
628
+ * already verified. Every `OxyServices` namespace, with no response cache,
629
+ * no token refresh and no fallback to this server's service identity; this
630
+ * server's own session is never touched.
631
+ *
632
+ * Prefer {@link withUser}, which disposes it for you. A client from here
633
+ * should be disposed when the request ends (`client.dispose()`) so a stray
634
+ * reference cannot keep using the bearer; one that is not holds no global
635
+ * resource and is collected normally.
636
+ *
637
+ * Throws `OxyAccessError` (`ACCESS_SESSION_REQUIRED`) for a request with no
638
+ * user session, including one authenticated by a service token.
639
+ */
640
+ asUser(source) {
641
+ const config = userClientConfig(this.config);
642
+ return new OxyUserClient({ ...config, baseURL: this.baseURL, cloudURL: this.cloudURL }, userBearerOf(source));
643
+ }
644
+ /**
645
+ * Run `use` with a client authenticated as the person behind `source`
646
+ * ({@link asUser}), and dispose it when `use` settles — success or failure.
647
+ *
648
+ * ```ts
649
+ * const accounts = await oxy.withUser(req, (user) => user.accounts.list());
650
+ * ```
651
+ */
652
+ async withUser(source, use) {
653
+ const user = this.asUser(source);
654
+ try {
655
+ return await use(user);
656
+ }
657
+ finally {
658
+ user.dispose();
659
+ }
660
+ }
661
+ /**
662
+ * What the person behind `req` may do in Oxy account `accountId` — an
663
+ * organization, store, channel or their own personal account: relationship,
664
+ * role, source and the EFFECTIVE permissions (inheritance, grants and
665
+ * revokes applied). Read with the request's own verified bearer.
666
+ *
667
+ * Cached per session for `appPermissionsCacheTtlMs` (30 s), positive
668
+ * answers only; pass `{ fresh: true }` before a write. Throws
669
+ * `OxyAccessError`: `ACCESS_SESSION_REQUIRED` (no session, a service token,
670
+ * or Oxy answered 401), `ACCESS_DENIED` (Oxy answered 403/404 — no access,
671
+ * or no such account), `ACCESS_UNAVAILABLE` (anything else; never a "no").
672
+ * Map it with `accessErrorStatus`. Gate on `has(permission)`, never on
673
+ * `role`.
674
+ */
675
+ accountAccessFor(req, accountId, options = {}) {
676
+ return this.callerAccess.account(req, accountId, options);
677
+ }
678
+ /**
679
+ * What the person behind `req` may do to Oxy application `applicationId`,
680
+ * through their membership of the account that owns it (developer RBAC:
681
+ * `app:read`, `app:update`, `webhooks:update`, `billing:manage`, …), plus
682
+ * its `ownerAccountId` and whether it is `official`. Read with the
683
+ * request's own verified bearer.
684
+ *
685
+ * Not {@link appPermissionsFor}: that one answers which of THIS backend's
686
+ * own declared operator keys the person holds. Same cache, options and
687
+ * errors as {@link accountAccessFor}; Oxy refuses (403) a caller with no
688
+ * `app:read`, so a successful answer always includes it.
689
+ */
690
+ applicationAccessFor(req, applicationId, options = {}) {
691
+ return this.callerAccess.application(req, applicationId, options);
610
692
  }
611
693
  /**
612
694
  * Drop cached session validations — all of them, one user's or one
@@ -45,10 +45,9 @@
45
45
  */
46
46
  import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
47
47
  import { logger } from '../logger/index.js';
48
+ import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, readCallerSession, } from './callerAuthority.js';
48
49
  /** Default cache lifetime of one answer. */
49
50
  export const APP_PERMISSIONS_CACHE_TTL_MS = 30000;
50
- /** Entries kept per server; the oldest goes first. */
51
- const APP_PERMISSIONS_CACHE_MAX = 1000;
52
51
  /** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
53
52
  export class OxyAppPermissionError extends Error {
54
53
  constructor(code, message, options = {}) {
@@ -73,20 +72,7 @@ export function appPermissionErrorStatus(error, deny = 404) {
73
72
  }
74
73
  }
75
74
  /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
76
- export function appPermissionErrorBody(status) {
77
- switch (status) {
78
- case 401:
79
- return { error: { code: 'unauthorized', message: 'Authentication required' } };
80
- case 403:
81
- return { error: { code: 'forbidden', message: 'Not permitted' } };
82
- case 404:
83
- return { error: { code: 'not_found', message: 'Not found' } };
84
- case 503:
85
- return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
86
- default:
87
- return { error: { code: 'internal_error', message: 'Internal error' } };
88
- }
89
- }
75
+ export const appPermissionErrorBody = authorityErrorBody;
90
76
  /** Throws at MOUNT time on a key that could never be held — a typo is a deploy error. */
91
77
  export function assertApplicationPermissionKey(permission) {
92
78
  if (!isApplicationPermissionKey(permission)) {
@@ -94,18 +80,7 @@ export function assertApplicationPermissionKey(permission) {
94
80
  }
95
81
  }
96
82
  function heldPermissions(applicationId, operatorId, permissions) {
97
- const sorted = Object.freeze([...new Set(permissions)].sort());
98
- const set = new Set(sorted);
99
- return Object.freeze({
100
- applicationId,
101
- operatorId,
102
- permissions: sorted,
103
- has: (permission) => set.has(permission),
104
- });
105
- }
106
- function errorStatus(error) {
107
- const status = error?.status;
108
- return typeof status === 'number' ? status : undefined;
83
+ return Object.freeze({ applicationId, operatorId, ...permissionSet(permissions) });
109
84
  }
110
85
  /**
111
86
  * The cache and the lookup behind `OxyServer.appPermissionsFor` and
@@ -114,22 +89,18 @@ function errorStatus(error) {
114
89
  export class OxyAppPermissionsResolver {
115
90
  constructor(host) {
116
91
  this.host = host;
117
- this.cache = new Map();
118
- this.pending = new Map();
92
+ this.cache = new CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
119
93
  }
120
94
  /**
121
95
  * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
122
96
  * never answers "nothing held" for a failure.
123
97
  */
124
98
  async resolve(req, options = {}) {
125
- if (req.serviceApp) {
126
- throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Application permissions are held by a person in a session; this request is authenticated by a service token');
127
- }
128
- const userId = typeof req.userId === 'string' && req.userId.length > 0 ? req.userId : null;
129
- const sessionId = typeof req.sessionId === 'string' && req.sessionId.length > 0 ? req.sessionId : null;
130
- const accessToken = typeof req.accessToken === 'string' && req.accessToken.length > 0 ? req.accessToken : null;
131
- if (!userId || !sessionId || !accessToken) {
132
- throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
99
+ const caller = readCallerSession(req);
100
+ if (!caller.ok) {
101
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
102
+ ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
103
+ : 'No verified user session on this request; mount middleware.auth() first');
133
104
  }
134
105
  let applicationId = options.applicationId ?? null;
135
106
  if (!applicationId) {
@@ -143,63 +114,15 @@ export class OxyAppPermissionsResolver {
143
114
  if (!applicationId) {
144
115
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
145
116
  }
146
- const key = JSON.stringify([applicationId, sessionId, userId]);
147
- const ttl = this.host.ttlMs;
148
- if (options.fresh !== true && ttl > 0) {
149
- const cached = this.cache.get(key);
150
- if (cached && cached.expiresAt > Date.now()) {
151
- return cached.value;
152
- }
153
- const inFlight = this.pending.get(key);
154
- if (inFlight)
155
- return inFlight;
156
- }
157
- const startedAt = Date.now();
158
- const lookup = this.lookup(applicationId, accessToken).then((value) => {
159
- if (ttl > 0)
160
- this.remember(key, value, startedAt, ttl);
161
- return value;
162
- });
163
- if (options.fresh === true || ttl <= 0)
164
- return lookup;
165
- this.pending.set(key, lookup);
166
- try {
167
- return await lookup;
168
- }
169
- finally {
170
- if (this.pending.get(key) === lookup)
171
- this.pending.delete(key);
172
- }
117
+ const appId = applicationId;
118
+ return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
173
119
  }
174
120
  /**
175
121
  * Forget cached answers: every one, or those about one person (matched as
176
122
  * either the session's user or the resolved operator).
177
123
  */
178
124
  invalidate(filter = {}) {
179
- if (!filter.userId) {
180
- this.cache.clear();
181
- return;
182
- }
183
- for (const [key, entry] of this.cache) {
184
- const [, , userId] = JSON.parse(key);
185
- if (userId === filter.userId || entry.value.operatorId === filter.userId) {
186
- this.cache.delete(key);
187
- }
188
- }
189
- }
190
- remember(key, value, startedAt, ttl) {
191
- // A slower, OLDER read must not overwrite a newer one — the newer one may
192
- // be the fresh read that saw a revocation.
193
- const existing = this.cache.get(key);
194
- if (existing && existing.startedAt > startedAt)
195
- return;
196
- this.cache.delete(key);
197
- this.cache.set(key, { value, startedAt, expiresAt: startedAt + ttl });
198
- if (this.cache.size > APP_PERMISSIONS_CACHE_MAX) {
199
- const oldest = this.cache.keys().next().value;
200
- if (oldest !== undefined)
201
- this.cache.delete(oldest);
202
- }
125
+ this.cache.invalidate(filter);
203
126
  }
204
127
  async lookup(applicationId, accessToken) {
205
128
  let answer;