@oxy.so/core 7.15.1 → 7.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +24 -13
  2. package/dist/cjs/server/OxyServer.js +85 -3
  3. package/dist/cjs/server/appPermissions.js +14 -92
  4. package/dist/cjs/server/callerAccess.js +151 -0
  5. package/dist/cjs/server/callerAuthority.js +206 -0
  6. package/dist/cjs/server/capabilityTicket.js +39 -6
  7. package/dist/cjs/server/cors.js +41 -2
  8. package/dist/cjs/server/index.js +33 -5
  9. package/dist/cjs/server/liveCapabilityTicket.js +4 -2
  10. package/dist/cjs/server/middleware.js +104 -2
  11. package/dist/cjs/server/namespaces.js +15 -4
  12. package/dist/cjs/server/safeFetch.js +29 -1
  13. package/dist/cjs/server/userClient.js +127 -0
  14. package/dist/cjs/utils/cache.js +58 -22
  15. package/dist/esm/server/OxyServer.js +85 -3
  16. package/dist/esm/server/appPermissions.js +12 -89
  17. package/dist/esm/server/callerAccess.js +145 -0
  18. package/dist/esm/server/callerAuthority.js +194 -0
  19. package/dist/esm/server/capabilityTicket.js +38 -6
  20. package/dist/esm/server/cors.js +39 -2
  21. package/dist/esm/server/index.js +26 -3
  22. package/dist/esm/server/liveCapabilityTicket.js +5 -3
  23. package/dist/esm/server/middleware.js +104 -2
  24. package/dist/esm/server/namespaces.js +15 -4
  25. package/dist/esm/server/safeFetch.js +29 -1
  26. package/dist/esm/server/userClient.js +121 -0
  27. package/dist/esm/utils/cache.js +57 -22
  28. package/dist/types/api/accounts.d.ts +8 -1
  29. package/dist/types/api/apps.d.ts +22 -4
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/server/OxyServer.d.ts +74 -7
  32. package/dist/types/server/appPermissions.d.ts +3 -13
  33. package/dist/types/server/auth.d.ts +5 -0
  34. package/dist/types/server/callerAccess.d.ts +86 -0
  35. package/dist/types/server/callerAuthority.d.ts +127 -0
  36. package/dist/types/server/capabilityTicket.d.ts +23 -1
  37. package/dist/types/server/cors.d.ts +39 -0
  38. package/dist/types/server/index.d.ts +31 -7
  39. package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
  40. package/dist/types/server/middleware.d.ts +34 -2
  41. package/dist/types/server/namespaces.d.ts +18 -2
  42. package/dist/types/server/safeFetch.d.ts +10 -0
  43. package/dist/types/server/userClient.d.ts +71 -0
  44. package/dist/types/utils/cache.d.ts +13 -0
  45. package/package.json +5 -5
  46. package/src/api/accounts.ts +8 -1
  47. package/src/api/apps.ts +23 -4
  48. package/src/index.ts +1 -0
  49. package/src/server/OxyServer.ts +126 -7
  50. package/src/server/appPermissions.ts +28 -105
  51. package/src/server/auth.ts +5 -0
  52. package/src/server/callerAccess.ts +232 -0
  53. package/src/server/callerAuthority.ts +267 -0
  54. package/src/server/capabilityTicket.ts +76 -17
  55. package/src/server/cors.ts +63 -2
  56. package/src/server/index.ts +38 -4
  57. package/src/server/liveCapabilityTicket.ts +12 -5
  58. package/src/server/middleware.ts +171 -4
  59. package/src/server/namespaces.ts +36 -5
  60. package/src/server/safeFetch.ts +31 -1
  61. package/src/server/userClient.ts +147 -0
  62. package/src/utils/cache.ts +56 -18
@@ -2,7 +2,8 @@ import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
2
2
  /**
3
3
  * The Express and Socket.IO middleware `OxyServer` mounts
4
4
  * (`server.middleware.auth()`, `.socket()`, `.service()`, `.requireScope()`,
5
- * `.requireAppPermission()`),
5
+ * `.requireAppPermission()`, `.requireAccountAccess()`,
6
+ * `.requireApplicationAccess()`),
6
7
  * and the Ed25519 service-token and account-event verification they share.
7
8
  *
8
9
  * Node only; reachable solely from `@oxy.so/core/server`.
@@ -18,6 +19,13 @@ import {
18
19
  type OxyAppPermissions,
19
20
  type RequireAppPermissionOptions,
20
21
  } from './appPermissions';
22
+ import {
23
+ accessErrorStatus,
24
+ authorityErrorBody,
25
+ OxyAccessError,
26
+ type OxyAccessDenyStatus,
27
+ } from './callerAuthority';
28
+ import type { CallerAccessOptions, OxyAccountAccess, OxyApplicationAccess } from './callerAccess';
21
29
  import {
22
30
  accountActorChainSchema,
23
31
  type AccountActorChain,
@@ -309,7 +317,8 @@ export interface AuthMiddlewareOptions {
309
317
  * Own product receivers use their SDK's canonical `oxy-service:<appId>` audience.
310
318
  * First-party tokens must name that recipient exactly. External scoped tokens
311
319
  * also retain the legacy `oxy-api` audience. Other audience overrides remain exact.
312
- * Oxy API defaults to `oxy-api`.
320
+ * Defaults to `OxyServerConfig.expectedAudience` when the server sets one,
321
+ * else `oxy-api`.
313
322
  */
314
323
  expectedAudience?: string;
315
324
  }
@@ -321,6 +330,8 @@ export interface AuthMiddlewareOptions {
321
330
  */
322
331
  export interface OxyMiddlewareHost {
323
332
  readonly baseURL: string;
333
+ /** `OxyServerConfig.expectedAudience`: the audience `auth()` accepts when a mount names none. */
334
+ readonly defaultAudience?: string;
324
335
  readonly jwksCache: ServiceTokenJwksCache;
325
336
  validateSession(
326
337
  sessionId: string,
@@ -353,6 +364,36 @@ export interface OxyMiddlewareHost {
353
364
  req: AppPermissionsRequest,
354
365
  options?: AppPermissionsOptions,
355
366
  ): Promise<OxyAppPermissions>;
367
+ /** `OxyServer.accountAccessFor`. */
368
+ accountAccessFor(
369
+ req: AuthReq,
370
+ accountId: string,
371
+ options?: CallerAccessOptions,
372
+ ): Promise<OxyAccountAccess>;
373
+ /** `OxyServer.applicationAccessFor`. */
374
+ applicationAccessFor(
375
+ req: AuthReq,
376
+ applicationId: string,
377
+ options?: CallerAccessOptions,
378
+ ): Promise<OxyApplicationAccess>;
379
+ }
380
+
381
+ /** Options of `middleware.requireAccountAccess` / `requireApplicationAccess`. */
382
+ export interface RequireAccessOptions extends CallerAccessOptions {
383
+ /** The route parameter that names the account or application (`':appId'` → `'appId'`). */
384
+ param: string;
385
+ /**
386
+ * What a refusal is answered: `403` (default; `401` without a session) or
387
+ * `404` (`404` without a session too), for a surface that must not reveal
388
+ * what exists. Oxy unreachable is always `503`.
389
+ */
390
+ deny?: OxyAccessDenyStatus;
391
+ /**
392
+ * `false` hands the {@link OxyAccessError} to `next(error)` instead of
393
+ * answering; `accessErrorStatus(error, deny)` gives the status the
394
+ * middleware would have used.
395
+ */
396
+ respond?: boolean;
356
397
  }
357
398
 
358
399
  /** Build the middleware set for one server. See `OxyServer.middleware`. */
@@ -443,7 +484,7 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
443
484
  optional = false,
444
485
  serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(),
445
486
  expectedIssuer = OXY_JWT_ISSUER,
446
- expectedAudience = OXY_JWT_AUDIENCE,
487
+ expectedAudience = host.defaultAudience ?? OXY_JWT_AUDIENCE,
447
488
  } = options;
448
489
  const strictPresentedBearer = Boolean(
449
490
  options.supervisedAction || options.strictOptionalBearer || options.liveSession,
@@ -1587,7 +1628,130 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1587
1628
  };
1588
1629
  }
1589
1630
 
1590
- return { auth, socket, service, requireScope, requireAppPermission };
1631
+ /**
1632
+ * One gate for both caller-access readers: read the id from the route
1633
+ * parameter, ask, admit on `permission`, answer a refusal by its outcome.
1634
+ */
1635
+ function requireCallerAccess<T extends { has(permission: string): boolean }>(
1636
+ method: 'requireAccountAccess' | 'requireApplicationAccess',
1637
+ permission: string,
1638
+ options: RequireAccessOptions,
1639
+ read: (req: AuthReq, id: string, lookup: CallerAccessOptions) => Promise<T>,
1640
+ admit: (req: AuthReq, access: T) => void,
1641
+ ) {
1642
+ const { param, deny = 403, respond = true, ...lookup } = options;
1643
+ if (typeof permission !== 'string' || permission.trim() === '') {
1644
+ throw new Error(`${method}: permission must be a non-empty string`);
1645
+ }
1646
+ if (typeof param !== 'string' || param.trim() === '') {
1647
+ throw new Error(`${method}: param must name the route parameter that holds the id`);
1648
+ }
1649
+ if (deny !== 403 && deny !== 404) {
1650
+ throw new Error(`${method}: deny must be 403 or 404`);
1651
+ }
1652
+
1653
+ return async (req: AuthReq, res: AuthRes, next: AuthNext): Promise<void> => {
1654
+ let refusal: OxyAccessError;
1655
+ const id = req.params?.[param];
1656
+ try {
1657
+ if (typeof id !== 'string' || id === '') {
1658
+ throw new OxyAccessError('ACCESS_DENIED', `No ${param} on this route`);
1659
+ }
1660
+ const access = await read(req, id, lookup);
1661
+ if (access.has(permission)) {
1662
+ admit(req, access);
1663
+ next();
1664
+ return;
1665
+ }
1666
+ refusal = new OxyAccessError('ACCESS_DENIED', `Missing permission ${permission}`, {
1667
+ permission,
1668
+ });
1669
+ } catch (error) {
1670
+ if (!(error instanceof OxyAccessError)) {
1671
+ next(error);
1672
+ return;
1673
+ }
1674
+ refusal = new OxyAccessError(error.code, error.message, {
1675
+ permission,
1676
+ cause: error.cause ?? error,
1677
+ });
1678
+ }
1679
+
1680
+ if (refusal.code === 'ACCESS_UNAVAILABLE') {
1681
+ logger.warn(`[oxy.auth] ${method} could not ask Oxy`, {
1682
+ component: 'auth',
1683
+ method,
1684
+ permission,
1685
+ path: req.path,
1686
+ });
1687
+ }
1688
+ if (!respond) {
1689
+ next(refusal);
1690
+ return;
1691
+ }
1692
+ const status = accessErrorStatus(refusal, deny);
1693
+ res.status(status).json(authorityErrorBody(status));
1694
+ };
1695
+ }
1696
+
1697
+ /**
1698
+ * Express middleware: the person behind the request must hold `permission`
1699
+ * in the Oxy ACCOUNT named by the route parameter `param` (an organization,
1700
+ * store, channel). Mount after `auth()`. On success the answer is on
1701
+ * `req.oxyAccountAccess`. See `OxyServer.accountAccessFor`.
1702
+ *
1703
+ * @example
1704
+ * ```typescript
1705
+ * router.patch('/stores/:storeId', oxy.middleware.requireAccountAccess('account:update', { param: 'storeId', fresh: true }), handler);
1706
+ * ```
1707
+ */
1708
+ function requireAccountAccess(permission: string, options: RequireAccessOptions) {
1709
+ return requireCallerAccess(
1710
+ 'requireAccountAccess',
1711
+ permission,
1712
+ options,
1713
+ (req, id, lookup) => host.accountAccessFor(req, id, lookup),
1714
+ (req, access) => {
1715
+ req.oxyAccountAccess = access;
1716
+ },
1717
+ );
1718
+ }
1719
+
1720
+ /**
1721
+ * Express middleware: the person behind the request must hold the
1722
+ * application permission `permission` (`app:read`, `app:update`,
1723
+ * `webhooks:update`, …) over the APPLICATION named by the route parameter
1724
+ * `param`, through the account that owns it. Mount after `auth()`. On
1725
+ * success the answer is on `req.oxyApplicationAccess`. See
1726
+ * `OxyServer.applicationAccessFor`; for THIS backend's own operator keys use
1727
+ * `requireAppPermission` instead.
1728
+ *
1729
+ * @example
1730
+ * ```typescript
1731
+ * router.get('/apps/:appId/payments', oxy.middleware.requireApplicationAccess('app:read', { param: 'appId' }), handler);
1732
+ * ```
1733
+ */
1734
+ function requireApplicationAccess(permission: string, options: RequireAccessOptions) {
1735
+ return requireCallerAccess(
1736
+ 'requireApplicationAccess',
1737
+ permission,
1738
+ options,
1739
+ (req, id, lookup) => host.applicationAccessFor(req, id, lookup),
1740
+ (req, access) => {
1741
+ req.oxyApplicationAccess = access;
1742
+ },
1743
+ );
1744
+ }
1745
+
1746
+ return {
1747
+ auth,
1748
+ socket,
1749
+ service,
1750
+ requireScope,
1751
+ requireAppPermission,
1752
+ requireAccountAccess,
1753
+ requireApplicationAccess,
1754
+ };
1591
1755
  }
1592
1756
 
1593
1757
  /** The middleware set `OxyServer.middleware` exposes. */
@@ -1891,6 +2055,9 @@ interface AuthReq {
1891
2055
  oxyActor?: AccountActorChain | null;
1892
2056
  oxyAuthRefusal?: OxyAuthRefusal;
1893
2057
  oxyAppPermissions?: OxyAppPermissions;
2058
+ oxyAccountAccess?: OxyAccountAccess;
2059
+ oxyApplicationAccess?: OxyApplicationAccess;
2060
+ params?: Record<string, string | string[] | undefined>;
1894
2061
  }
1895
2062
 
1896
2063
  interface AuthRes {
@@ -142,6 +142,29 @@ const HEADER_SAFE_PATTERN = /^[\x20-\x7e]*$/;
142
142
  export type ServiceUploadBody = Uint8Array | ArrayBuffer | Blob;
143
143
 
144
144
  /** What `uploadAppFile` and `uploadUserMedia` may say about the bytes besides their type. */
145
+ /**
146
+ * Per-call transport options for a service call, overriding the server's
147
+ * `requestTimeout` / `enableRetry` for that one call — so one `OxyServer` can
148
+ * make a short, single-shot write and a patient read side by side.
149
+ */
150
+ export interface ServiceCallOptions {
151
+ /** Abort the call. */
152
+ signal?: AbortSignal;
153
+ /** Per-attempt timeout in ms. */
154
+ timeout?: number;
155
+ /** `false` sends the call once; a transient failure is the caller's to retry (an outbox). */
156
+ retry?: boolean;
157
+ }
158
+
159
+ /** The `RequestOptions` half of {@link ServiceCallOptions}, only the fields that were set. */
160
+ function serviceCallRequestOptions(options: ServiceCallOptions): RequestOptions {
161
+ return {
162
+ ...(options.signal ? { signal: options.signal } : {}),
163
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
164
+ ...(options.retry !== undefined ? { retry: options.retry } : {}),
165
+ };
166
+ }
167
+
145
168
  export interface ServiceUploadDescription {
146
169
  /**
147
170
  * Stored as the file's original name. Sent as a header, so only printable
@@ -305,12 +328,19 @@ export class ServerAssetsApi extends AssetsApi {
305
328
  * changes the file's visibility.
306
329
  * - `remove`: only references this application holds. Never trashes or
307
330
  * deletes the file.
331
+ *
332
+ * Both take {@link ServiceCallOptions} for each request they send — e.g.
333
+ * `{ retry: false, timeout: 5000 }` from an outbox that retries by itself.
308
334
  */
309
335
  readonly references = {
310
- add: (references: ServiceFileReference[]): Promise<ServiceFileReferenceResult[]> =>
311
- this.sendReferences('POST', references),
312
- remove: (references: ServiceFileReference[]): Promise<ServiceFileReferenceResult[]> =>
313
- this.sendReferences('DELETE', references),
336
+ add: (
337
+ references: ServiceFileReference[],
338
+ options: ServiceCallOptions = {},
339
+ ): Promise<ServiceFileReferenceResult[]> => this.sendReferences('POST', references, options),
340
+ remove: (
341
+ references: ServiceFileReference[],
342
+ options: ServiceCallOptions = {},
343
+ ): Promise<ServiceFileReferenceResult[]> => this.sendReferences('DELETE', references, options),
314
344
  /**
315
345
  * Reconcile this application's references of ONE entity type with its
316
346
  * real inventory (ADR 0043 D6) — the safety net for a reference a crash or
@@ -516,6 +546,7 @@ export class ServerAssetsApi extends AssetsApi {
516
546
  private async sendReferences(
517
547
  method: 'POST' | 'DELETE',
518
548
  references: ServiceFileReference[],
549
+ options: ServiceCallOptions,
519
550
  ): Promise<ServiceFileReferenceResult[]> {
520
551
  const results: ServiceFileReferenceResult[] = [];
521
552
  for (let start = 0; start < references.length; start += SERVICE_REFERENCES_CHUNK_SIZE) {
@@ -526,7 +557,7 @@ export class ServerAssetsApi extends AssetsApi {
526
557
  method,
527
558
  '/assets/service/references',
528
559
  { references: chunk },
529
- { cache: false },
560
+ { ...serviceCallRequestOptions(options), cache: false },
530
561
  );
531
562
  results.push(...res.results);
532
563
  }
@@ -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.
@@ -40,6 +50,7 @@ import type { LookupAddress, LookupAllOptions, LookupOneOptions } from 'node:dns
40
50
  import type { LookupFunction } from 'node:net';
41
51
  import { isIP } from 'node:net';
42
52
  import { URL } from 'node:url';
53
+ import { isLoopbackOrigin, isOfficialWebOrigin } from '../utils/officialOrigins';
43
54
  import { withDeadline } from './deadline';
44
55
  import { extractEmbeddedIpv4, ipv4InCidr, ipv6InCidr } from './ipCidr';
45
56
 
@@ -297,6 +308,23 @@ export async function assertSafePublicUrl(
297
308
  };
298
309
  }
299
310
 
311
+ /** What a refusal of a first-party Oxy host adds to its reason. */
312
+ const FIRST_PARTY_HOST_HINT =
313
+ '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';
314
+
315
+ /** `reason`, plus the first-party hint when `rawUrl` names an official Oxy host. */
316
+ function rejectionReason(rawUrl: string, reason: string): string {
317
+ let origin: string;
318
+ try {
319
+ origin = new URL(rawUrl).origin;
320
+ } catch {
321
+ return reason;
322
+ }
323
+ return isOfficialWebOrigin(origin) && !isLoopbackOrigin(origin)
324
+ ? `${reason} (${FIRST_PARTY_HOST_HINT})`
325
+ : reason;
326
+ }
327
+
300
328
  /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
301
329
  export class SsrfRejection extends Error {
302
330
  constructor(reason: string) {
@@ -484,7 +512,9 @@ export async function safeFetch(
484
512
  signal,
485
513
  });
486
514
  if (!guard.ok) {
487
- throw guard.incomplete ? new UpstreamError(guard.reason) : new SsrfRejection(guard.reason);
515
+ throw guard.incomplete
516
+ ? new UpstreamError(guard.reason)
517
+ : new SsrfRejection(rejectionReason(currentUrl, guard.reason));
488
518
  }
489
519
 
490
520
  const target = new URL(currentUrl);
@@ -0,0 +1,147 @@
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
+
36
+ import { OxyServices, type OxyConfig } from '../OxyServices';
37
+ import type { HttpMethod } from '../client/context';
38
+ import type { RequestOptions, ResponsePayload } from '../HttpService';
39
+ import { isBearerToken, OxyAccessError, type OxyCallerRequest } from './callerAuthority';
40
+
41
+ /**
42
+ * Whose bearer a user client carries: a request `middleware.auth()` resolved
43
+ * to a user session (its `req.accessToken`), or a bearer string the caller has
44
+ * already verified (a Socket.IO handshake's `socket.accessToken`, say).
45
+ */
46
+ export type OxyUserSource = string | OxyCallerRequest;
47
+
48
+ /** The transport settings a user client inherits from its server; never auth, cache or app identity. */
49
+ const INHERITED_TRANSPORT_KEYS = [
50
+ 'baseURL',
51
+ 'cloudURL',
52
+ 'requestTimeout',
53
+ 'enableRetry',
54
+ 'maxRetries',
55
+ 'retryDelay',
56
+ 'maxConcurrentRequests',
57
+ 'requestQueueSize',
58
+ 'enableLogging',
59
+ 'logLevel',
60
+ 'onRequestStart',
61
+ 'onRequestEnd',
62
+ 'onRequestError',
63
+ ] as const satisfies readonly (keyof OxyConfig)[];
64
+
65
+ /** The transport half of a server config, for a user client. */
66
+ export function userClientConfig(config: Readonly<OxyConfig>): OxyConfig {
67
+ const transport: Partial<Record<keyof OxyConfig, unknown>> = {};
68
+ for (const key of INHERITED_TRANSPORT_KEYS) {
69
+ if (config[key] !== undefined) transport[key] = config[key];
70
+ }
71
+ return { ...(transport as OxyConfig), enableCache: false };
72
+ }
73
+
74
+ /**
75
+ * The user bearer `source` carries. Throws `OxyAccessError`
76
+ * (`ACCESS_SESSION_REQUIRED`) for a request with no user session — anonymous,
77
+ * or authenticated by a SERVICE token, whose bearer is the calling service's
78
+ * and never a person's.
79
+ */
80
+ export function userBearerOf(source: OxyUserSource): string {
81
+ if (typeof source === 'string') {
82
+ if (!isBearerToken(source)) {
83
+ throw new OxyAccessError('ACCESS_SESSION_REQUIRED', 'A user client needs a bearer token');
84
+ }
85
+ return source;
86
+ }
87
+ if (source.serviceApp) {
88
+ throw new OxyAccessError(
89
+ 'ACCESS_SESSION_REQUIRED',
90
+ 'This request is authenticated by a service token; a user client needs a person’s session',
91
+ );
92
+ }
93
+ const token = source.accessToken;
94
+ if (typeof token !== 'string' || !isBearerToken(token)) {
95
+ throw new OxyAccessError(
96
+ 'ACCESS_SESSION_REQUIRED',
97
+ 'No verified user session on this request; mount middleware.auth() first',
98
+ );
99
+ }
100
+ return token;
101
+ }
102
+
103
+ /**
104
+ * Every `OxyServices` namespace, authenticated as one person. Create it with
105
+ * `OxyServer.withUser` (disposed for you) or `OxyServer.asUser` (dispose it
106
+ * yourself); the constructor is not part of the public contract.
107
+ */
108
+ export class OxyUserClient extends OxyServices {
109
+ private bearer: string | null;
110
+
111
+ /** @internal Use `OxyServer.asUser` / `OxyServer.withUser`. */
112
+ constructor(config: OxyConfig, accessToken: string) {
113
+ super(config);
114
+ this.bearer = accessToken;
115
+ // For the namespaces that read the session's identity (`session.userId`).
116
+ this.http.setTokens(accessToken);
117
+ }
118
+
119
+ /**
120
+ * Every namespace call goes through here: it carries this client's bearer
121
+ * explicitly, which also keeps the response out of any cache and out of
122
+ * request de-duplication. Rejects once the client is disposed.
123
+ */
124
+ override async request<T>(
125
+ method: HttpMethod,
126
+ url: string,
127
+ data?: unknown,
128
+ options: RequestOptions = {},
129
+ ): Promise<ResponsePayload<T>> {
130
+ const bearer = this.bearer;
131
+ if (!bearer) throw new Error('This Oxy user client has been disposed');
132
+ // This client speaks as its person only: no caller-supplied credential,
133
+ // in any letter case, rides along.
134
+ const headers: Record<string, string> = {};
135
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
136
+ if (name.toLowerCase() !== 'authorization') headers[name] = value;
137
+ }
138
+ headers.Authorization = `Bearer ${bearer}`;
139
+ return super.request<T>(method, url, data, { ...options, headers });
140
+ }
141
+
142
+ /** Forget the bearer: every later call rejects. */
143
+ override dispose(): void {
144
+ this.bearer = null;
145
+ super.dispose();
146
+ }
147
+ }
@@ -57,6 +57,10 @@ export class TTLCache<T> {
57
57
  private misses = 0;
58
58
 
59
59
  private maxEntries: number;
60
+ /** Registered for the shared expiry sweep (see {@link registerCacheForCleanup}). */
61
+ private swept = false;
62
+ /** The sweep generation this cache registered in; {@link stopAllCleanupIntervals} ends it. */
63
+ private sweptGeneration = -1;
60
64
 
61
65
  /**
62
66
  * Create a new TTL cache
@@ -85,6 +89,7 @@ export class TTLCache<T> {
85
89
  if (now >= entry.expiresAt) {
86
90
  this.cache.delete(key);
87
91
  this.misses++;
92
+ this.syncSweep();
88
93
  return null;
89
94
  }
90
95
 
@@ -117,7 +122,7 @@ export class TTLCache<T> {
117
122
  const oldest = this.cache.keys().next().value;
118
123
  if (oldest !== undefined) this.cache.delete(oldest);
119
124
  }
120
- if (!cleanupInterval && activeCaches.has(this)) updateCleanupInterval();
125
+ this.syncSweep();
121
126
  }
122
127
 
123
128
  /**
@@ -127,9 +132,7 @@ export class TTLCache<T> {
127
132
  */
128
133
  delete(key: string): boolean {
129
134
  const deleted = this.cache.delete(key);
130
- // Only the delete that EMPTIES this cache can let the timer stop; checking
131
- // on every delete made bulk invalidation O(deletes x caches).
132
- if (deleted && this.cache.size === 0 && activeCaches.has(this)) updateCleanupInterval();
135
+ if (deleted) this.syncSweep();
133
136
  return deleted;
134
137
  }
135
138
 
@@ -140,7 +143,7 @@ export class TTLCache<T> {
140
143
  this.cache.clear();
141
144
  this.hits = 0;
142
145
  this.misses = 0;
143
- if (activeCaches.has(this)) updateCleanupInterval();
146
+ this.syncSweep();
144
147
  }
145
148
 
146
149
  /**
@@ -155,7 +158,7 @@ export class TTLCache<T> {
155
158
  removed++;
156
159
  }
157
160
  }
158
- if (removed > 0 && this.cache.size === 0 && activeCaches.has(this)) updateCleanupInterval();
161
+ if (removed > 0) this.syncSweep();
159
162
  return removed;
160
163
  }
161
164
 
@@ -170,6 +173,7 @@ export class TTLCache<T> {
170
173
 
171
174
  if (Date.now() >= entry.expiresAt) {
172
175
  this.cache.delete(key);
176
+ this.syncSweep();
173
177
  return false;
174
178
  }
175
179
 
@@ -191,6 +195,7 @@ export class TTLCache<T> {
191
195
  this.cache.delete(key);
192
196
  }
193
197
  }
198
+ this.syncSweep();
194
199
 
195
200
  return validKeys;
196
201
  }
@@ -209,10 +214,31 @@ export class TTLCache<T> {
209
214
  removed++;
210
215
  }
211
216
  }
217
+ if (removed > 0) this.syncSweep();
212
218
 
213
219
  return removed;
214
220
  }
215
221
 
222
+ /**
223
+ * Start or stop being swept: a registered cache is held by the shared sweep
224
+ * ONLY while it has entries to expire. An empty cache is referenced by
225
+ * nothing global, so a client nobody disposed is collected with its cache.
226
+ */
227
+ setSwept(swept: boolean): void {
228
+ this.swept = swept;
229
+ this.sweptGeneration = sweepGeneration;
230
+ this.syncSweep();
231
+ }
232
+
233
+ private syncSweep(): void {
234
+ if (this.swept && this.sweptGeneration === sweepGeneration && this.cache.size > 0) {
235
+ sweptCaches.add(this);
236
+ } else {
237
+ sweptCaches.delete(this);
238
+ }
239
+ updateCleanupInterval();
240
+ }
241
+
216
242
  /**
217
243
  * Get cache size (number of entries)
218
244
  */
@@ -255,41 +281,47 @@ export function createCache<T>(ttl: number = 5 * 60 * 1000): TTLCache<T> {
255
281
  }
256
282
 
257
283
  /**
258
- * Global cache cleanup interval (runs every minute)
259
- * This helps prevent memory leaks from expired cache entries
284
+ * The shared expiry sweep: one unref'd interval, running only while some
285
+ * registered cache holds entries.
286
+ *
287
+ * `sweptCaches` holds exactly the registered caches that are NON-EMPTY. A
288
+ * cache joins when it gains its first entry and leaves when it is emptied — by
289
+ * the sweep itself once its entries expire — so the module never pins a cache
290
+ * that has nothing to expire. That is what keeps a short-lived client that was
291
+ * never disposed (one per request, say) collectable: the most it can be held
292
+ * for is its entries' TTL.
260
293
  */
261
294
  let cleanupInterval: ReturnType<typeof setInterval> | null = null;
262
- const activeCaches = new Set<TTLCache<unknown>>();
295
+ const sweptCaches = new Set<TTLCache<unknown>>();
296
+ /** Bumped by {@link stopAllCleanupIntervals}, which ends every registration. */
297
+ let sweepGeneration = 0;
263
298
 
264
299
  /**
265
300
  * Register a cache for automatic cleanup
266
301
  * @param cache Cache instance to register
267
302
  */
268
303
  export function registerCacheForCleanup(cache: TTLCache<unknown>): void {
269
- activeCaches.add(cache);
270
- updateCleanupInterval();
304
+ cache.setSwept(true);
271
305
  }
272
306
 
273
307
  /** Empty registered caches need no timer; importing a client must stay inert. */
274
308
  function updateCleanupInterval(): void {
275
- const hasEntries = [...activeCaches].some((cache) => cache.size() > 0);
276
- if (!hasEntries) {
309
+ if (sweptCaches.size === 0) {
277
310
  if (cleanupInterval) clearInterval(cleanupInterval);
278
311
  cleanupInterval = null;
279
312
  return;
280
313
  }
281
314
  if (cleanupInterval) return;
282
315
  cleanupInterval = setInterval(() => {
283
- for (const cache of activeCaches) cache.cleanup();
284
- updateCleanupInterval();
316
+ // `cleanup()` removes an emptied cache from the set as it goes.
317
+ for (const cache of [...sweptCaches]) cache.cleanup();
285
318
  }, 60_000);
286
319
  cleanupInterval.unref?.();
287
320
  }
288
321
 
289
322
  /** Unregister a cache without changing its entries or TTL behavior. */
290
323
  export function unregisterCacheFromCleanup(cache: TTLCache<unknown>): void {
291
- activeCaches.delete(cache);
292
- updateCleanupInterval();
324
+ cache.setSwept(false);
293
325
  }
294
326
 
295
327
  /**
@@ -297,9 +329,15 @@ export function unregisterCacheFromCleanup(cache: TTLCache<unknown>): void {
297
329
  * This will clear the interval and unregister all caches
298
330
  */
299
331
  export function stopAllCleanupIntervals(): void {
332
+ sweepGeneration += 1;
333
+ sweptCaches.clear();
300
334
  if (cleanupInterval) {
301
335
  clearInterval(cleanupInterval);
302
336
  cleanupInterval = null;
303
337
  }
304
- activeCaches.clear();
338
+ }
339
+
340
+ /** How many caches the shared sweep currently holds (tests). @internal */
341
+ export function sweptCacheCount(): number {
342
+ return sweptCaches.size;
305
343
  }