@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
@@ -1,5 +1,6 @@
1
1
  import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
2
2
  import { appPermissionErrorBody, appPermissionErrorStatus, assertApplicationPermissionKey, OxyAppPermissionError, } from './appPermissions.js';
3
+ import { accessErrorStatus, authorityErrorBody, OxyAccessError, } from './callerAuthority.js';
3
4
  import { accountActorChainSchema, } from '@oxy.so/contracts';
4
5
  import { jwtDecode } from 'jwt-decode';
5
6
  import { loadNodeCrypto } from '@oxy.so/protocol';
@@ -130,7 +131,7 @@ export function createOxyMiddleware(host) {
130
131
  * @returns Express middleware function
131
132
  */
132
133
  function auth(options = {}) {
133
- const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
134
+ const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = host.defaultAudience ?? OXY_JWT_AUDIENCE, } = options;
134
135
  const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
135
136
  /**
136
137
  * Make a refusal observable to the HOST, without telling the client
@@ -1166,7 +1167,108 @@ export function createOxyMiddleware(host) {
1166
1167
  res.status(status).json(appPermissionErrorBody(status));
1167
1168
  };
1168
1169
  }
1169
- return { auth, socket, service, requireScope, requireAppPermission };
1170
+ /**
1171
+ * One gate for both caller-access readers: read the id from the route
1172
+ * parameter, ask, admit on `permission`, answer a refusal by its outcome.
1173
+ */
1174
+ function requireCallerAccess(method, permission, options, read, admit) {
1175
+ const { param, deny = 403, respond = true, ...lookup } = options;
1176
+ if (typeof permission !== 'string' || permission.trim() === '') {
1177
+ throw new Error(`${method}: permission must be a non-empty string`);
1178
+ }
1179
+ if (typeof param !== 'string' || param.trim() === '') {
1180
+ throw new Error(`${method}: param must name the route parameter that holds the id`);
1181
+ }
1182
+ if (deny !== 403 && deny !== 404) {
1183
+ throw new Error(`${method}: deny must be 403 or 404`);
1184
+ }
1185
+ return async (req, res, next) => {
1186
+ let refusal;
1187
+ const id = req.params?.[param];
1188
+ try {
1189
+ if (typeof id !== 'string' || id === '') {
1190
+ throw new OxyAccessError('ACCESS_DENIED', `No ${param} on this route`);
1191
+ }
1192
+ const access = await read(req, id, lookup);
1193
+ if (access.has(permission)) {
1194
+ admit(req, access);
1195
+ next();
1196
+ return;
1197
+ }
1198
+ refusal = new OxyAccessError('ACCESS_DENIED', `Missing permission ${permission}`, {
1199
+ permission,
1200
+ });
1201
+ }
1202
+ catch (error) {
1203
+ if (!(error instanceof OxyAccessError)) {
1204
+ next(error);
1205
+ return;
1206
+ }
1207
+ refusal = new OxyAccessError(error.code, error.message, {
1208
+ permission,
1209
+ cause: error.cause ?? error,
1210
+ });
1211
+ }
1212
+ if (refusal.code === 'ACCESS_UNAVAILABLE') {
1213
+ logger.warn(`[oxy.auth] ${method} could not ask Oxy`, {
1214
+ component: 'auth',
1215
+ method,
1216
+ permission,
1217
+ path: req.path,
1218
+ });
1219
+ }
1220
+ if (!respond) {
1221
+ next(refusal);
1222
+ return;
1223
+ }
1224
+ const status = accessErrorStatus(refusal, deny);
1225
+ res.status(status).json(authorityErrorBody(status));
1226
+ };
1227
+ }
1228
+ /**
1229
+ * Express middleware: the person behind the request must hold `permission`
1230
+ * in the Oxy ACCOUNT named by the route parameter `param` (an organization,
1231
+ * store, channel). Mount after `auth()`. On success the answer is on
1232
+ * `req.oxyAccountAccess`. See `OxyServer.accountAccessFor`.
1233
+ *
1234
+ * @example
1235
+ * ```typescript
1236
+ * router.patch('/stores/:storeId', oxy.middleware.requireAccountAccess('account:update', { param: 'storeId', fresh: true }), handler);
1237
+ * ```
1238
+ */
1239
+ function requireAccountAccess(permission, options) {
1240
+ return requireCallerAccess('requireAccountAccess', permission, options, (req, id, lookup) => host.accountAccessFor(req, id, lookup), (req, access) => {
1241
+ req.oxyAccountAccess = access;
1242
+ });
1243
+ }
1244
+ /**
1245
+ * Express middleware: the person behind the request must hold the
1246
+ * application permission `permission` (`app:read`, `app:update`,
1247
+ * `webhooks:update`, …) over the APPLICATION named by the route parameter
1248
+ * `param`, through the account that owns it. Mount after `auth()`. On
1249
+ * success the answer is on `req.oxyApplicationAccess`. See
1250
+ * `OxyServer.applicationAccessFor`; for THIS backend's own operator keys use
1251
+ * `requireAppPermission` instead.
1252
+ *
1253
+ * @example
1254
+ * ```typescript
1255
+ * router.get('/apps/:appId/payments', oxy.middleware.requireApplicationAccess('app:read', { param: 'appId' }), handler);
1256
+ * ```
1257
+ */
1258
+ function requireApplicationAccess(permission, options) {
1259
+ return requireCallerAccess('requireApplicationAccess', permission, options, (req, id, lookup) => host.applicationAccessFor(req, id, lookup), (req, access) => {
1260
+ req.oxyApplicationAccess = access;
1261
+ });
1262
+ }
1263
+ return {
1264
+ auth,
1265
+ socket,
1266
+ service,
1267
+ requireScope,
1268
+ requireAppPermission,
1269
+ requireAccountAccess,
1270
+ requireApplicationAccess,
1271
+ };
1170
1272
  }
1171
1273
  function isExactNonEmptyServiceClaim(value) {
1172
1274
  return typeof value === 'string' && value.length > 0 && value.trim() === value;
@@ -58,6 +58,14 @@ const SERVICE_RECONCILE_RATE_LIMIT_MAX_WAIT_MS = 60000;
58
58
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
59
59
  /** A header value fetch accepts on every runtime: printable ASCII only. */
60
60
  const HEADER_SAFE_PATTERN = /^[\x20-\x7e]*$/;
61
+ /** The `RequestOptions` half of {@link ServiceCallOptions}, only the fields that were set. */
62
+ function serviceCallRequestOptions(options) {
63
+ return {
64
+ ...(options.signal ? { signal: options.signal } : {}),
65
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
66
+ ...(options.retry !== undefined ? { retry: options.retry } : {}),
67
+ };
68
+ }
61
69
  /** The headers both streamed service uploads share. */
62
70
  function uploadHeaders(mime, description) {
63
71
  return {
@@ -147,10 +155,13 @@ export class ServerAssetsApi extends AssetsApi {
147
155
  * changes the file's visibility.
148
156
  * - `remove`: only references this application holds. Never trashes or
149
157
  * deletes the file.
158
+ *
159
+ * Both take {@link ServiceCallOptions} for each request they send — e.g.
160
+ * `{ retry: false, timeout: 5000 }` from an outbox that retries by itself.
150
161
  */
151
162
  this.references = {
152
- add: (references) => this.sendReferences('POST', references),
153
- remove: (references) => this.sendReferences('DELETE', references),
163
+ add: (references, options = {}) => this.sendReferences('POST', references, options),
164
+ remove: (references, options = {}) => this.sendReferences('DELETE', references, options),
154
165
  /**
155
166
  * Reconcile this application's references of ONE entity type with its
156
167
  * real inventory (ADR 0043 D6) — the safety net for a reference a crash or
@@ -309,13 +320,13 @@ export class ServerAssetsApi extends AssetsApi {
309
320
  await release();
310
321
  return result;
311
322
  }
312
- async sendReferences(method, references) {
323
+ async sendReferences(method, references, options) {
313
324
  const results = [];
314
325
  for (let start = 0; start < references.length; start += SERVICE_REFERENCES_CHUNK_SIZE) {
315
326
  const chunk = references
316
327
  .slice(start, start + SERVICE_REFERENCES_CHUNK_SIZE)
317
328
  .map(({ fileId, entityType, entityId }) => ({ fileId, entityType, entityId }));
318
- const res = await lane(this.ctx).request(method, '/assets/service/references', { references: chunk }, { cache: false });
329
+ const res = await lane(this.ctx).request(method, '/assets/service/references', { references: chunk }, { ...serviceCallRequestOptions(options), cache: false });
319
330
  results.push(...res.results);
320
331
  }
321
332
  return results;
@@ -28,6 +28,16 @@
28
28
  * response; without one, a server that sends its headers and stalls holds
29
29
  * any other reader open.
30
30
  *
31
+ * NOT FOR OXY'S OWN HOSTS
32
+ * -----------------------
33
+ * `safeFetch` is for UNTRUSTED or caller-influenced URLs only. Never use it to
34
+ * reach a first-party host (`api.oxy.so`, any `*.oxy.so`, an Oxy product's
35
+ * API): inside the VPC those names resolve to the internal load balancer
36
+ * (split-horizon DNS), a private address this module refuses by design. Call
37
+ * Oxy through `OxyServer` — its namespaces, `serviceRequest`, or
38
+ * `createServiceClient({ baseURL, audience }).fetch` for another Oxy product.
39
+ * A refusal of such a host says so in its message.
40
+ *
31
41
  * Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
32
42
  * exported solely from `@oxy.so/core/server`. It MUST NOT be reachable from the
33
43
  * browser `@oxy.so/core` entry.
@@ -37,6 +47,7 @@ import https from 'node:https';
37
47
  import { lookup as dnsLookup } from 'node:dns/promises';
38
48
  import { isIP } from 'node:net';
39
49
  import { URL } from 'node:url';
50
+ import { isLoopbackOrigin, isOfficialWebOrigin } from '../utils/officialOrigins.js';
40
51
  import { withDeadline } from './deadline.js';
41
52
  import { extractEmbeddedIpv4, ipv4InCidr, ipv6InCidr } from './ipCidr.js';
42
53
  /** Maximum accepted length of an input URL (DoS guard). */
@@ -246,6 +257,21 @@ export async function assertSafePublicUrl(rawUrl, options = {}) {
246
257
  family: chosen.family === 4 ? 4 : 6,
247
258
  };
248
259
  }
260
+ /** What a refusal of a first-party Oxy host adds to its reason. */
261
+ 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';
262
+ /** `reason`, plus the first-party hint when `rawUrl` names an official Oxy host. */
263
+ function rejectionReason(rawUrl, reason) {
264
+ let origin;
265
+ try {
266
+ origin = new URL(rawUrl).origin;
267
+ }
268
+ catch {
269
+ return reason;
270
+ }
271
+ return isOfficialWebOrigin(origin) && !isLoopbackOrigin(origin)
272
+ ? `${reason} (${FIRST_PARTY_HOST_HINT})`
273
+ : reason;
274
+ }
249
275
  /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
250
276
  export class SsrfRejection extends Error {
251
277
  constructor(reason) {
@@ -342,7 +368,9 @@ export async function safeFetch(rawUrl, options = {}) {
342
368
  signal,
343
369
  });
344
370
  if (!guard.ok) {
345
- throw guard.incomplete ? new UpstreamError(guard.reason) : new SsrfRejection(guard.reason);
371
+ throw guard.incomplete
372
+ ? new UpstreamError(guard.reason)
373
+ : new SsrfRejection(rejectionReason(currentUrl, guard.reason));
346
374
  }
347
375
  const target = new URL(currentUrl);
348
376
  const requestOptions = buildRequestOptions(target, guard.ip, guard.family, method, baseHeaders, signal);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Calling Oxy AS the person behind a request.
3
+ *
4
+ * A backend's `OxyServer` is a process singleton authenticated as the backend
5
+ * itself; a user's bearer must never be planted on it (`setTokens`,
6
+ * `session.setAccessToken`), because every concurrent request would then speak
7
+ * as that user. A read that must be answered for the person — their own
8
+ * account graph, an application they manage, their profile — goes through an
9
+ * {@link OxyUserClient}: every namespace of `OxyServices`, bound to that one
10
+ * bearer, sharing no mutable state with the server.
11
+ *
12
+ * ```ts
13
+ * // Express, after oxy.middleware.auth():
14
+ * const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
15
+ * ```
16
+ *
17
+ * What makes it safe to create one per request:
18
+ *
19
+ * - **No response cache.** The client is built with `enableCache: false`, so
20
+ * one person's answers are never served to anyone, including the same
21
+ * person's next request, and nothing is registered with the module-wide
22
+ * cache sweep.
23
+ * - **Exactly this bearer, every time.** Every request carries it as an
24
+ * explicit `Authorization` header: never refreshed (that is the user's own
25
+ * client's job), never dropped for an expired or opaque token, never
26
+ * replaced by the server's service token. Oxy answers a revoked or expired
27
+ * bearer 401, which the access readers map to `ACCESS_SESSION_REQUIRED`.
28
+ * - **Deterministic end.** `withUser` disposes the client when the callback
29
+ * settles, so a reference that escapes the callback holds no bearer. A
30
+ * client from `asUser` that is never disposed holds no global resource
31
+ * either; it is collected with its last reference.
32
+ *
33
+ * Node only; reachable solely from `@oxy.so/core/server`.
34
+ */
35
+ import { OxyServices } from '../OxyServices.js';
36
+ import { isBearerToken, OxyAccessError } from './callerAuthority.js';
37
+ /** The transport settings a user client inherits from its server; never auth, cache or app identity. */
38
+ const INHERITED_TRANSPORT_KEYS = [
39
+ 'baseURL',
40
+ 'cloudURL',
41
+ 'requestTimeout',
42
+ 'enableRetry',
43
+ 'maxRetries',
44
+ 'retryDelay',
45
+ 'maxConcurrentRequests',
46
+ 'requestQueueSize',
47
+ 'enableLogging',
48
+ 'logLevel',
49
+ 'onRequestStart',
50
+ 'onRequestEnd',
51
+ 'onRequestError',
52
+ ];
53
+ /** The transport half of a server config, for a user client. */
54
+ export function userClientConfig(config) {
55
+ const transport = {};
56
+ for (const key of INHERITED_TRANSPORT_KEYS) {
57
+ if (config[key] !== undefined)
58
+ transport[key] = config[key];
59
+ }
60
+ return { ...transport, enableCache: false };
61
+ }
62
+ /**
63
+ * The user bearer `source` carries. Throws `OxyAccessError`
64
+ * (`ACCESS_SESSION_REQUIRED`) for a request with no user session — anonymous,
65
+ * or authenticated by a SERVICE token, whose bearer is the calling service's
66
+ * and never a person's.
67
+ */
68
+ export function userBearerOf(source) {
69
+ if (typeof source === 'string') {
70
+ if (!isBearerToken(source)) {
71
+ throw new OxyAccessError('ACCESS_SESSION_REQUIRED', 'A user client needs a bearer token');
72
+ }
73
+ return source;
74
+ }
75
+ if (source.serviceApp) {
76
+ throw new OxyAccessError('ACCESS_SESSION_REQUIRED', 'This request is authenticated by a service token; a user client needs a person’s session');
77
+ }
78
+ const token = source.accessToken;
79
+ if (typeof token !== 'string' || !isBearerToken(token)) {
80
+ throw new OxyAccessError('ACCESS_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
81
+ }
82
+ return token;
83
+ }
84
+ /**
85
+ * Every `OxyServices` namespace, authenticated as one person. Create it with
86
+ * `OxyServer.withUser` (disposed for you) or `OxyServer.asUser` (dispose it
87
+ * yourself); the constructor is not part of the public contract.
88
+ */
89
+ export class OxyUserClient extends OxyServices {
90
+ /** @internal Use `OxyServer.asUser` / `OxyServer.withUser`. */
91
+ constructor(config, accessToken) {
92
+ super(config);
93
+ this.bearer = accessToken;
94
+ // For the namespaces that read the session's identity (`session.userId`).
95
+ this.http.setTokens(accessToken);
96
+ }
97
+ /**
98
+ * Every namespace call goes through here: it carries this client's bearer
99
+ * explicitly, which also keeps the response out of any cache and out of
100
+ * request de-duplication. Rejects once the client is disposed.
101
+ */
102
+ async request(method, url, data, options = {}) {
103
+ const bearer = this.bearer;
104
+ if (!bearer)
105
+ throw new Error('This Oxy user client has been disposed');
106
+ // This client speaks as its person only: no caller-supplied credential,
107
+ // in any letter case, rides along.
108
+ const headers = {};
109
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
110
+ if (name.toLowerCase() !== 'authorization')
111
+ headers[name] = value;
112
+ }
113
+ headers.Authorization = `Bearer ${bearer}`;
114
+ return super.request(method, url, data, { ...options, headers });
115
+ }
116
+ /** Forget the bearer: every later call rejects. */
117
+ dispose() {
118
+ this.bearer = null;
119
+ super.dispose();
120
+ }
121
+ }
@@ -41,6 +41,10 @@ export class TTLCache {
41
41
  this.cache = new Map();
42
42
  this.hits = 0;
43
43
  this.misses = 0;
44
+ /** Registered for the shared expiry sweep (see {@link registerCacheForCleanup}). */
45
+ this.swept = false;
46
+ /** The sweep generation this cache registered in; {@link stopAllCleanupIntervals} ends it. */
47
+ this.sweptGeneration = -1;
44
48
  this.defaultTTL = defaultTTL;
45
49
  this.maxEntries = maxEntries;
46
50
  }
@@ -59,6 +63,7 @@ export class TTLCache {
59
63
  if (now >= entry.expiresAt) {
60
64
  this.cache.delete(key);
61
65
  this.misses++;
66
+ this.syncSweep();
62
67
  return null;
63
68
  }
64
69
  // Re-insert so Map order is recency order and eviction drops the LRU.
@@ -90,8 +95,7 @@ export class TTLCache {
90
95
  if (oldest !== undefined)
91
96
  this.cache.delete(oldest);
92
97
  }
93
- if (!cleanupInterval && activeCaches.has(this))
94
- updateCleanupInterval();
98
+ this.syncSweep();
95
99
  }
96
100
  /**
97
101
  * Delete a specific cache entry
@@ -100,10 +104,8 @@ export class TTLCache {
100
104
  */
101
105
  delete(key) {
102
106
  const deleted = this.cache.delete(key);
103
- // Only the delete that EMPTIES this cache can let the timer stop; checking
104
- // on every delete made bulk invalidation O(deletes x caches).
105
- if (deleted && this.cache.size === 0 && activeCaches.has(this))
106
- updateCleanupInterval();
107
+ if (deleted)
108
+ this.syncSweep();
107
109
  return deleted;
108
110
  }
109
111
  /**
@@ -113,8 +115,7 @@ export class TTLCache {
113
115
  this.cache.clear();
114
116
  this.hits = 0;
115
117
  this.misses = 0;
116
- if (activeCaches.has(this))
117
- updateCleanupInterval();
118
+ this.syncSweep();
118
119
  }
119
120
  /**
120
121
  * Delete every entry whose key matches, in ONE pass.
@@ -128,8 +129,8 @@ export class TTLCache {
128
129
  removed++;
129
130
  }
130
131
  }
131
- if (removed > 0 && this.cache.size === 0 && activeCaches.has(this))
132
- updateCleanupInterval();
132
+ if (removed > 0)
133
+ this.syncSweep();
133
134
  return removed;
134
135
  }
135
136
  /**
@@ -143,6 +144,7 @@ export class TTLCache {
143
144
  return false;
144
145
  if (Date.now() >= entry.expiresAt) {
145
146
  this.cache.delete(key);
147
+ this.syncSweep();
146
148
  return false;
147
149
  }
148
150
  return true;
@@ -162,6 +164,7 @@ export class TTLCache {
162
164
  this.cache.delete(key);
163
165
  }
164
166
  }
167
+ this.syncSweep();
165
168
  return validKeys;
166
169
  }
167
170
  /**
@@ -177,8 +180,29 @@ export class TTLCache {
177
180
  removed++;
178
181
  }
179
182
  }
183
+ if (removed > 0)
184
+ this.syncSweep();
180
185
  return removed;
181
186
  }
187
+ /**
188
+ * Start or stop being swept: a registered cache is held by the shared sweep
189
+ * ONLY while it has entries to expire. An empty cache is referenced by
190
+ * nothing global, so a client nobody disposed is collected with its cache.
191
+ */
192
+ setSwept(swept) {
193
+ this.swept = swept;
194
+ this.sweptGeneration = sweepGeneration;
195
+ this.syncSweep();
196
+ }
197
+ syncSweep() {
198
+ if (this.swept && this.sweptGeneration === sweepGeneration && this.cache.size > 0) {
199
+ sweptCaches.add(this);
200
+ }
201
+ else {
202
+ sweptCaches.delete(this);
203
+ }
204
+ updateCleanupInterval();
205
+ }
182
206
  /**
183
207
  * Get cache size (number of entries)
184
208
  */
@@ -217,23 +241,30 @@ export function createCache(ttl = 5 * 60 * 1000) {
217
241
  return new TTLCache(ttl);
218
242
  }
219
243
  /**
220
- * Global cache cleanup interval (runs every minute)
221
- * This helps prevent memory leaks from expired cache entries
244
+ * The shared expiry sweep: one unref'd interval, running only while some
245
+ * registered cache holds entries.
246
+ *
247
+ * `sweptCaches` holds exactly the registered caches that are NON-EMPTY. A
248
+ * cache joins when it gains its first entry and leaves when it is emptied — by
249
+ * the sweep itself once its entries expire — so the module never pins a cache
250
+ * that has nothing to expire. That is what keeps a short-lived client that was
251
+ * never disposed (one per request, say) collectable: the most it can be held
252
+ * for is its entries' TTL.
222
253
  */
223
254
  let cleanupInterval = null;
224
- const activeCaches = new Set();
255
+ const sweptCaches = new Set();
256
+ /** Bumped by {@link stopAllCleanupIntervals}, which ends every registration. */
257
+ let sweepGeneration = 0;
225
258
  /**
226
259
  * Register a cache for automatic cleanup
227
260
  * @param cache Cache instance to register
228
261
  */
229
262
  export function registerCacheForCleanup(cache) {
230
- activeCaches.add(cache);
231
- updateCleanupInterval();
263
+ cache.setSwept(true);
232
264
  }
233
265
  /** Empty registered caches need no timer; importing a client must stay inert. */
234
266
  function updateCleanupInterval() {
235
- const hasEntries = [...activeCaches].some((cache) => cache.size() > 0);
236
- if (!hasEntries) {
267
+ if (sweptCaches.size === 0) {
237
268
  if (cleanupInterval)
238
269
  clearInterval(cleanupInterval);
239
270
  cleanupInterval = null;
@@ -242,25 +273,29 @@ function updateCleanupInterval() {
242
273
  if (cleanupInterval)
243
274
  return;
244
275
  cleanupInterval = setInterval(() => {
245
- for (const cache of activeCaches)
276
+ // `cleanup()` removes an emptied cache from the set as it goes.
277
+ for (const cache of [...sweptCaches])
246
278
  cache.cleanup();
247
- updateCleanupInterval();
248
279
  }, 60000);
249
280
  cleanupInterval.unref?.();
250
281
  }
251
282
  /** Unregister a cache without changing its entries or TTL behavior. */
252
283
  export function unregisterCacheFromCleanup(cache) {
253
- activeCaches.delete(cache);
254
- updateCleanupInterval();
284
+ cache.setSwept(false);
255
285
  }
256
286
  /**
257
287
  * Stop all cleanup intervals (useful for testing)
258
288
  * This will clear the interval and unregister all caches
259
289
  */
260
290
  export function stopAllCleanupIntervals() {
291
+ sweepGeneration += 1;
292
+ sweptCaches.clear();
261
293
  if (cleanupInterval) {
262
294
  clearInterval(cleanupInterval);
263
295
  cleanupInterval = null;
264
296
  }
265
- activeCaches.clear();
297
+ }
298
+ /** How many caches the shared sweep currently holds (tests). @internal */
299
+ export function sweptCacheCount() {
300
+ return sweptCaches.size;
266
301
  }
@@ -1,4 +1,4 @@
1
- import type { OrganizationType } from '@oxy.so/contracts';
1
+ import type { AccountAccessGrant, OrganizationType } from '@oxy.so/contracts';
2
2
  /**
3
3
  * `oxy.accounts` — the unified account graph (`/accounts`).
4
4
  *
@@ -118,6 +118,13 @@ export interface AccountNode {
118
118
  * ownership is implicit). Use `callerMembership.permissions` to gate UI.
119
119
  */
120
120
  callerMembership: AccountMember | null;
121
+ /**
122
+ * The caller's EFFECTIVE access to this account — role, permissions and
123
+ * where it comes from, `self` included (where `callerMembership` is `null`).
124
+ * Present on single-account reads (`accounts.get`); a backend asks
125
+ * `OxyServer.accountAccessFor`.
126
+ */
127
+ callerAccess?: AccountAccessGrant;
121
128
  /** Number of direct child accounts (present when the API computes it). */
122
129
  childCount?: number;
123
130
  /** Direct children, populated when the list is requested with `tree:true`. */
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import type { AppAssertionChallengeRequest, AppAssertionChallengeResponse, AppAssertionRequest, AppAssertionResponse, AppAttestKeyRequest, AppAttestKeyResponse } from '@oxy.so/contracts';
19
19
  import type { OxyContext } from '../client/context';
20
- import type { AccountMember, AccountSuccessResult } from './accounts';
20
+ import type { AccountMember, AccountRole, AccountSuccessResult } from './accounts';
21
21
  /**
22
22
  * Application classification. Set only by Oxy platform staff — never editable
23
23
  * through the normal member-facing update path.
@@ -92,12 +92,30 @@ export interface Application {
92
92
  createdAt: string;
93
93
  updatedAt: string;
94
94
  /**
95
- * The caller's effective membership in the OWNING account (direct or
96
- * inherited), embedded by the API on list/detail responses, or `null` when the
97
- * caller has no membership. Use `callerMembership.permissions` to gate UI.
95
+ * The caller's access to this application, derived from their effective
96
+ * role over the OWNING account (direct or inherited), embedded by the API on
97
+ * list/detail responses, or `null` when the caller has no membership. Use
98
+ * `callerMembership.permissions` to gate UI; a backend asks
99
+ * `OxyServer.applicationAccessFor`.
100
+ *
101
+ * The wire shape is {@link ApplicationCallerMembership} — only `role` and
102
+ * `permissions` of this `AccountMember` type are present. The type is
103
+ * corrected in 8.0 (ADR 0054); changing it in a minor would break compiles.
98
104
  */
99
105
  callerMembership?: AccountMember | null;
100
106
  }
107
+ /**
108
+ * The caller's access to an application as the API actually embeds it: the role they
109
+ * hold over the owning account and the APPLICATION permissions it maps to
110
+ * (`app:read`, `app:update`, `webhooks:update`, …). There is no per-application
111
+ * member row, so this is not an `AccountMember`.
112
+ */
113
+ export interface ApplicationCallerMembership {
114
+ role: AccountRole;
115
+ permissions: string[];
116
+ source: 'account';
117
+ ownerAccountId: string;
118
+ }
101
119
  /**
102
120
  * Client-facing ApplicationCredential shape (an application's OAuth client
103
121
  * credentials). The raw secret is NEVER part of this shape — it is returned
@@ -43,7 +43,7 @@ export { buildUserDid } from './api/identity';
43
43
  export type { IdentityApi, VerifyDomainResult, RemoveDomainResult, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './api/identity';
44
44
  export { ACCOUNT_CATEGORY_IDS, MAX_ACCOUNT_CATEGORIES, SELECTABLE_ACCOUNT_CATEGORY_IDS, isSelectableAccountCategoryId, kindAcceptsAccountCategories, } from './api/accounts';
45
45
  export type { AccountsApi, AccountKind, AccountCategoryId, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, AccountSuccessResult, SwitchAccountResult, } from './api/accounts';
46
- export type { AppsApi, AppAssertionsApi, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, RotateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, PublicApplication, ConnectedApp, ConnectedMcpClient, ApplicationDeleteResult, ApplicationFilesWebhook, } from './api/apps';
46
+ export type { AppsApi, AppAssertionsApi, Application, ApplicationCallerMembership, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, RotateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, PublicApplication, ConnectedApp, ConnectedMcpClient, ApplicationDeleteResult, ApplicationFilesWebhook, } from './api/apps';
47
47
  export type { LinkedAccountsApi } from './api/linkedAccounts';
48
48
  export type { AgencyApi, AccountCapabilityPolicy, AvailableCapabilityCatalog, CapabilityExecutionAuthorization, CreateDelegationGrantInput, DelegationCatalogBinding, DelegationGrantView, PutAccountCapabilityPolicyInput, UpdateDelegationGrantInput, } from './api/agency';
49
49
  export type { StoreApi, StoreCategory, StoreRating, StoreListingSummary, StoreListingDetail, StoreScreenshot, StoreScreenshotPlatform, StoreReview, StoreOwnReview, WriteStoreReviewInput, StoreListingStatus, PublisherListing, WriteListingInput, AddScreenshotInput, UpdateScreenshotInput, StorePage, StorePageOptions, StoreReviewsOptions, } from './api/store';