@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
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * The Express and Socket.IO middleware `OxyServer` mounts
3
3
  * (`server.middleware.auth()`, `.socket()`, `.service()`, `.requireScope()`,
4
- * `.requireAppPermission()`),
4
+ * `.requireAppPermission()`, `.requireAccountAccess()`,
5
+ * `.requireApplicationAccess()`),
5
6
  * and the Ed25519 service-token and account-event verification they share.
6
7
  *
7
8
  * Node only; reachable solely from `@oxy.so/core/server`.
8
9
  */
9
10
  import type { OxyRequestUser } from './auth';
10
11
  import { type AppPermissionsOptions, type AppPermissionsRequest, type OxyAppPermissions, type RequireAppPermissionOptions } from './appPermissions';
12
+ import { type OxyAccessDenyStatus } from './callerAuthority';
13
+ import type { CallerAccessOptions, OxyAccountAccess, OxyApplicationAccess } from './callerAccess';
11
14
  import { type AccountActorChain, type SupervisedAction, type ServiceIntrospection } from '@oxy.so/contracts';
12
15
  import type { ApiError, User } from '../models/interfaces';
13
16
  import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
@@ -221,7 +224,8 @@ export interface AuthMiddlewareOptions {
221
224
  * Own product receivers use their SDK's canonical `oxy-service:<appId>` audience.
222
225
  * First-party tokens must name that recipient exactly. External scoped tokens
223
226
  * also retain the legacy `oxy-api` audience. Other audience overrides remain exact.
224
- * Oxy API defaults to `oxy-api`.
227
+ * Defaults to `OxyServerConfig.expectedAudience` when the server sets one,
228
+ * else `oxy-api`.
225
229
  */
226
230
  expectedAudience?: string;
227
231
  }
@@ -232,6 +236,8 @@ export interface AuthMiddlewareOptions {
232
236
  */
233
237
  export interface OxyMiddlewareHost {
234
238
  readonly baseURL: string;
239
+ /** `OxyServerConfig.expectedAudience`: the audience `auth()` accepts when a mount names none. */
240
+ readonly defaultAudience?: string;
235
241
  readonly jwksCache: ServiceTokenJwksCache;
236
242
  validateSession(sessionId: string, options?: {
237
243
  deviceFingerprint?: string;
@@ -258,6 +264,27 @@ export interface OxyMiddlewareHost {
258
264
  introspectServiceToken?(token: string): Promise<ServiceIntrospection>;
259
265
  /** `OxyServer.appPermissionsFor` — what the person behind a request holds for this app. */
260
266
  appPermissionsFor(req: AppPermissionsRequest, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
267
+ /** `OxyServer.accountAccessFor`. */
268
+ accountAccessFor(req: AuthReq, accountId: string, options?: CallerAccessOptions): Promise<OxyAccountAccess>;
269
+ /** `OxyServer.applicationAccessFor`. */
270
+ applicationAccessFor(req: AuthReq, applicationId: string, options?: CallerAccessOptions): Promise<OxyApplicationAccess>;
271
+ }
272
+ /** Options of `middleware.requireAccountAccess` / `requireApplicationAccess`. */
273
+ export interface RequireAccessOptions extends CallerAccessOptions {
274
+ /** The route parameter that names the account or application (`':appId'` → `'appId'`). */
275
+ param: string;
276
+ /**
277
+ * What a refusal is answered: `403` (default; `401` without a session) or
278
+ * `404` (`404` without a session too), for a surface that must not reveal
279
+ * what exists. Oxy unreachable is always `503`.
280
+ */
281
+ deny?: OxyAccessDenyStatus;
282
+ /**
283
+ * `false` hands the {@link OxyAccessError} to `next(error)` instead of
284
+ * answering; `accessErrorStatus(error, deny)` gives the status the
285
+ * middleware would have used.
286
+ */
287
+ respond?: boolean;
261
288
  }
262
289
  /** Build the middleware set for one server. See `OxyServer.middleware`. */
263
290
  export declare function createOxyMiddleware(host: OxyMiddlewareHost): {
@@ -276,6 +303,8 @@ export declare function createOxyMiddleware(host: OxyMiddlewareHost): {
276
303
  }) => (req: AuthReq, res: AuthRes, next: AuthNext) => Promise<void>;
277
304
  requireScope: (scope: string) => (req: AuthReq, res: AuthRes, next: AuthNext) => void;
278
305
  requireAppPermission: (permission: string, options?: RequireAppPermissionOptions) => (req: AuthReq, res: AuthRes, next: AuthNext) => Promise<void>;
306
+ requireAccountAccess: (permission: string, options: RequireAccessOptions) => (req: AuthReq, res: AuthRes, next: AuthNext) => Promise<void>;
307
+ requireApplicationAccess: (permission: string, options: RequireAccessOptions) => (req: AuthReq, res: AuthRes, next: AuthNext) => Promise<void>;
279
308
  };
280
309
  /** The middleware set `OxyServer.middleware` exposes. */
281
310
  export type OxyMiddleware = ReturnType<typeof createOxyMiddleware>;
@@ -318,6 +347,9 @@ interface AuthReq {
318
347
  oxyActor?: AccountActorChain | null;
319
348
  oxyAuthRefusal?: OxyAuthRefusal;
320
349
  oxyAppPermissions?: OxyAppPermissions;
350
+ oxyAccountAccess?: OxyAccountAccess;
351
+ oxyApplicationAccess?: OxyApplicationAccess;
352
+ params?: Record<string, string | string[] | undefined>;
321
353
  }
322
354
  interface AuthRes {
323
355
  status(code: number): AuthRes;
@@ -22,6 +22,19 @@ import { type BatchLookup } from '../utils/batchLookup';
22
22
  /** The bytes of a streamed service upload: sent as the whole request body. */
23
23
  export type ServiceUploadBody = Uint8Array | ArrayBuffer | Blob;
24
24
  /** What `uploadAppFile` and `uploadUserMedia` may say about the bytes besides their type. */
25
+ /**
26
+ * Per-call transport options for a service call, overriding the server's
27
+ * `requestTimeout` / `enableRetry` for that one call — so one `OxyServer` can
28
+ * make a short, single-shot write and a patient read side by side.
29
+ */
30
+ export interface ServiceCallOptions {
31
+ /** Abort the call. */
32
+ signal?: AbortSignal;
33
+ /** Per-attempt timeout in ms. */
34
+ timeout?: number;
35
+ /** `false` sends the call once; a transient failure is the caller's to retry (an outbox). */
36
+ retry?: boolean;
37
+ }
25
38
  export interface ServiceUploadDescription {
26
39
  /**
27
40
  * Stored as the file's original name. Sent as a header, so only printable
@@ -62,10 +75,13 @@ export declare class ServerAssetsApi extends AssetsApi {
62
75
  * changes the file's visibility.
63
76
  * - `remove`: only references this application holds. Never trashes or
64
77
  * deletes the file.
78
+ *
79
+ * Both take {@link ServiceCallOptions} for each request they send — e.g.
80
+ * `{ retry: false, timeout: 5000 }` from an outbox that retries by itself.
65
81
  */
66
82
  readonly references: {
67
- add: (references: ServiceFileReference[]) => Promise<ServiceFileReferenceResult[]>;
68
- remove: (references: ServiceFileReference[]) => Promise<ServiceFileReferenceResult[]>;
83
+ add: (references: ServiceFileReference[], options?: ServiceCallOptions) => Promise<ServiceFileReferenceResult[]>;
84
+ remove: (references: ServiceFileReference[], options?: ServiceCallOptions) => Promise<ServiceFileReferenceResult[]>;
69
85
  /**
70
86
  * Reconcile this application's references of ONE entity type with its
71
87
  * real inventory (ADR 0043 D6) — the safety net for a reference a crash or
@@ -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.
@@ -0,0 +1,71 @@
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, type OxyConfig } from '../OxyServices';
36
+ import type { HttpMethod } from '../client/context';
37
+ import type { RequestOptions, ResponsePayload } from '../HttpService';
38
+ import { type OxyCallerRequest } from './callerAuthority';
39
+ /**
40
+ * Whose bearer a user client carries: a request `middleware.auth()` resolved
41
+ * to a user session (its `req.accessToken`), or a bearer string the caller has
42
+ * already verified (a Socket.IO handshake's `socket.accessToken`, say).
43
+ */
44
+ export type OxyUserSource = string | OxyCallerRequest;
45
+ /** The transport half of a server config, for a user client. */
46
+ export declare function userClientConfig(config: Readonly<OxyConfig>): OxyConfig;
47
+ /**
48
+ * The user bearer `source` carries. Throws `OxyAccessError`
49
+ * (`ACCESS_SESSION_REQUIRED`) for a request with no user session — anonymous,
50
+ * or authenticated by a SERVICE token, whose bearer is the calling service's
51
+ * and never a person's.
52
+ */
53
+ export declare function userBearerOf(source: OxyUserSource): string;
54
+ /**
55
+ * Every `OxyServices` namespace, authenticated as one person. Create it with
56
+ * `OxyServer.withUser` (disposed for you) or `OxyServer.asUser` (dispose it
57
+ * yourself); the constructor is not part of the public contract.
58
+ */
59
+ export declare class OxyUserClient extends OxyServices {
60
+ private bearer;
61
+ /** @internal Use `OxyServer.asUser` / `OxyServer.withUser`. */
62
+ constructor(config: OxyConfig, accessToken: string);
63
+ /**
64
+ * Every namespace call goes through here: it carries this client's bearer
65
+ * explicitly, which also keeps the response out of any cache and out of
66
+ * request de-duplication. Rejects once the client is disposed.
67
+ */
68
+ request<T>(method: HttpMethod, url: string, data?: unknown, options?: RequestOptions): Promise<ResponsePayload<T>>;
69
+ /** Forget the bearer: every later call rejects. */
70
+ dispose(): void;
71
+ }
@@ -45,6 +45,10 @@ export declare class TTLCache<T> {
45
45
  private hits;
46
46
  private misses;
47
47
  private maxEntries;
48
+ /** Registered for the shared expiry sweep (see {@link registerCacheForCleanup}). */
49
+ private swept;
50
+ /** The sweep generation this cache registered in; {@link stopAllCleanupIntervals} ends it. */
51
+ private sweptGeneration;
48
52
  /**
49
53
  * Create a new TTL cache
50
54
  * @param defaultTTL Default TTL in milliseconds (default: 5 minutes)
@@ -99,6 +103,13 @@ export declare class TTLCache<T> {
99
103
  * @returns Number of entries removed
100
104
  */
101
105
  cleanup(): number;
106
+ /**
107
+ * Start or stop being swept: a registered cache is held by the shared sweep
108
+ * ONLY while it has entries to expire. An empty cache is referenced by
109
+ * nothing global, so a client nobody disposed is collected with its cache.
110
+ */
111
+ setSwept(swept: boolean): void;
112
+ private syncSweep;
102
113
  /**
103
114
  * Get cache size (number of entries)
104
115
  */
@@ -133,3 +144,5 @@ export declare function unregisterCacheFromCleanup(cache: TTLCache<unknown>): vo
133
144
  * This will clear the interval and unregister all caches
134
145
  */
135
146
  export declare function stopAllCleanupIntervals(): void;
147
+ /** How many caches the shared sweep currently holds (tests). @internal */
148
+ export declare function sweptCacheCount(): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.15.1",
3
+ "version": "7.17.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -182,7 +182,7 @@
182
182
  "@noble/ciphers": "^2.4.0",
183
183
  "@noble/hashes": "^2.4.0",
184
184
  "@oxy.so/contracts": "6.5.0",
185
- "@oxy.so/protocol": "^1.6.0",
185
+ "@oxy.so/protocol": "^1.6.1",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^2.4.0",
188
188
  "buffer": "^6.0.3",
@@ -198,7 +198,7 @@
198
198
  "@react-native-async-storage/async-storage": "<4.0.0",
199
199
  "expo-crypto": "<59.0.0",
200
200
  "expo-secure-store": "<59.0.0",
201
- "express": "^4.0.0",
201
+ "express": "^4.0.0 || ^5.0.0",
202
202
  "@aws-sdk/credential-provider-node": ">=3.972.83 <4",
203
203
  "@aws-sdk/credential-provider-ini": ">=3.973.16 <4"
204
204
  },
@@ -224,12 +224,12 @@
224
224
  },
225
225
  "devDependencies": {
226
226
  "@react-native-async-storage/async-storage": "^3.1.1",
227
- "@types/express": "^4.17.25",
227
+ "@types/express": "^5.0.6",
228
228
  "@types/invariant": "^2.2.37",
229
229
  "@types/node": "^26.6.6",
230
230
  "expo-crypto": "~58.0.7",
231
231
  "expo-secure-store": "~58.0.2",
232
- "express": "^4.22.2",
232
+ "express": "^5.3.0",
233
233
  "express-rate-limit": "^8.6.0",
234
234
  "jest": "^30.5.2",
235
235
  "regexpu-core": "^6.5.3",
@@ -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
  *
@@ -163,6 +163,13 @@ export interface AccountNode {
163
163
  * ownership is implicit). Use `callerMembership.permissions` to gate UI.
164
164
  */
165
165
  callerMembership: AccountMember | null;
166
+ /**
167
+ * The caller's EFFECTIVE access to this account — role, permissions and
168
+ * where it comes from, `self` included (where `callerMembership` is `null`).
169
+ * Present on single-account reads (`accounts.get`); a backend asks
170
+ * `OxyServer.accountAccessFor`.
171
+ */
172
+ callerAccess?: AccountAccessGrant;
166
173
  /** Number of direct child accounts (present when the API computes it). */
167
174
  childCount?: number;
168
175
  /** Direct children, populated when the list is requested with `tree:true`. */
package/src/api/apps.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  parseContract,
31
31
  } from '@oxy.so/contracts/client';
32
32
  import type { OxyContext } from '../client/context';
33
- import type { AccountMember, AccountSuccessResult } from './accounts';
33
+ import type { AccountMember, AccountRole, AccountSuccessResult } from './accounts';
34
34
 
35
35
  const SHORT_TTL = 60 * 1000;
36
36
  const MEDIUM_TTL = 2 * 60 * 1000;
@@ -123,13 +123,32 @@ export interface Application {
123
123
  createdAt: string;
124
124
  updatedAt: string;
125
125
  /**
126
- * The caller's effective membership in the OWNING account (direct or
127
- * inherited), embedded by the API on list/detail responses, or `null` when the
128
- * caller has no membership. Use `callerMembership.permissions` to gate UI.
126
+ * The caller's access to this application, derived from their effective
127
+ * role over the OWNING account (direct or inherited), embedded by the API on
128
+ * list/detail responses, or `null` when the caller has no membership. Use
129
+ * `callerMembership.permissions` to gate UI; a backend asks
130
+ * `OxyServer.applicationAccessFor`.
131
+ *
132
+ * The wire shape is {@link ApplicationCallerMembership} — only `role` and
133
+ * `permissions` of this `AccountMember` type are present. The type is
134
+ * corrected in 8.0 (ADR 0054); changing it in a minor would break compiles.
129
135
  */
130
136
  callerMembership?: AccountMember | null;
131
137
  }
132
138
 
139
+ /**
140
+ * The caller's access to an application as the API actually embeds it: the role they
141
+ * hold over the owning account and the APPLICATION permissions it maps to
142
+ * (`app:read`, `app:update`, `webhooks:update`, …). There is no per-application
143
+ * member row, so this is not an `AccountMember`.
144
+ */
145
+ export interface ApplicationCallerMembership {
146
+ role: AccountRole;
147
+ permissions: string[];
148
+ source: 'account';
149
+ ownerAccountId: string;
150
+ }
151
+
133
152
  /**
134
153
  * Client-facing ApplicationCredential shape (an application's OAuth client
135
154
  * credentials). The raw secret is NEVER part of this shape — it is returned
package/src/index.ts CHANGED
@@ -159,6 +159,7 @@ export type {
159
159
  AppsApi,
160
160
  AppAssertionsApi,
161
161
  Application,
162
+ ApplicationCallerMembership,
162
163
  ApplicationType,
163
164
  ApplicationStatus,
164
165
  ApplicationCredential,
@@ -106,6 +106,14 @@ import {
106
106
  ServerNotificationsApi,
107
107
  ServerReputationApi,
108
108
  } from './namespaces';
109
+ import {
110
+ OxyCallerAccessResolver,
111
+ type CallerAccessOptions,
112
+ type OxyAccountAccess,
113
+ type OxyApplicationAccess,
114
+ } from './callerAccess';
115
+ import type { OxyCallerRequest } from './callerAuthority';
116
+ import { OxyUserClient, userBearerOf, userClientConfig, type OxyUserSource } from './userClient';
109
117
  import { ServerSafetyApi } from './safety';
110
118
  import { SESSION_VALIDATION_CACHE_TTL_MS, ServerSessionApi } from './session';
111
119
  import { OxyServiceEventFeed } from './serviceEvents';
@@ -145,12 +153,21 @@ export interface OxyServerConfig extends OxyConfig {
145
153
  */
146
154
  applicationId?: string;
147
155
  /**
148
- * How long one application-permissions answer is reused, per session
149
- * (default 30 000 ms). This is the longest a revoked grant keeps working on
150
- * this backend for a read that does not pass `fresh: true`. `0` disables the
151
- * cache.
156
+ * How long one caller-authority answer — `appPermissionsFor`,
157
+ * `accountAccessFor`, `applicationAccessFor` and their middleware — is
158
+ * reused, per session (default 30 000 ms). This is the longest a revoked
159
+ * grant keeps working on this backend for a read that does not pass
160
+ * `fresh: true`. `0` disables the cache. Only positive answers are kept.
152
161
  */
153
162
  appPermissionsCacheTtlMs?: number;
163
+ /**
164
+ * The service-token audience this backend accepts when a mount names none:
165
+ * its own `oxy-service:<applicationId>`. Every `middleware.auth()` /
166
+ * `middleware.service()` mount — and so `createOxyAuthMiddleware`,
167
+ * `createOptionalOxyAuth` and `createOxyRateLimit` — uses it unless it
168
+ * passes `expectedAudience` itself. Unset, they accept `oxy-api` (unchanged).
169
+ */
170
+ expectedAudience?: string;
154
171
  /**
155
172
  * How long one successful user-bearer validation (`session.validate` with an
156
173
  * `accessToken`, which `middleware.auth()` runs on every request) is reused
@@ -316,6 +333,7 @@ export class OxyServer extends OxyServices {
316
333
  private readonly configuredApplicationId: string | null;
317
334
  private readonly sessionValidationTtlMs: number;
318
335
  private readonly appPermissions: OxyAppPermissionsResolver;
336
+ private readonly callerAccess: OxyCallerAccessResolver;
319
337
  /** Opened the first time the validation cache would be used; see `./serviceEvents.ts`. */
320
338
  private serviceEventFeed: OxyServiceEventFeed | null = null;
321
339
  private disposed = false;
@@ -364,15 +382,27 @@ export class OxyServer extends OxyServices {
364
382
  defaultApplicationId: () => this.defaultApplicationId(),
365
383
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
366
384
  });
385
+ this.callerAccess = new OxyCallerAccessResolver({
386
+ ttlMs: ttl,
387
+ readAccount: (accountId, accessToken) =>
388
+ this.withUser(accessToken, (user) => user.accounts.get(accountId)),
389
+ readApplication: (applicationId, accessToken) =>
390
+ this.withUser(accessToken, (user) => user.apps.get(applicationId)),
391
+ });
392
+ const defaultAudience = config.expectedAudience?.trim() || undefined;
367
393
  this.middleware = createOxyMiddleware({
368
394
  get baseURL() {
369
395
  return server.baseURL;
370
396
  },
397
+ defaultAudience,
371
398
  jwksCache: this.jwksCache,
372
399
  validateSession: (sessionId, options) => this.session.validate(sessionId, options),
373
400
  verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
374
401
  introspectServiceToken: (token) => this.introspectServiceToken(token),
375
402
  appPermissionsFor: (req, options) => this.appPermissionsFor(req, options),
403
+ accountAccessFor: (req, accountId, options) => this.accountAccessFor(req, accountId, options),
404
+ applicationAccessFor: (req, applicationId, options) =>
405
+ this.applicationAccessFor(req, applicationId, options),
376
406
  });
377
407
  }
378
408
 
@@ -909,12 +939,101 @@ export class OxyServer extends OxyServices {
909
939
  }
910
940
 
911
941
  /**
912
- * Drop cached application-permission answers — all of them, or one
913
- * person's (as the session user or the resolved operator). For a backend
914
- * that learns of a revocation out of band and will not wait out the TTL.
942
+ * Drop cached caller-authority answers — application permissions and
943
+ * account / application access — all of them, or one person's (as the
944
+ * session user or the resolved operator). For a backend that learns of a
945
+ * revocation out of band and will not wait out the TTL.
915
946
  */
916
947
  invalidateAppPermissions(filter: { userId?: string } = {}): void {
917
948
  this.appPermissions.invalidate(filter);
949
+ this.callerAccess.invalidate(filter);
950
+ }
951
+
952
+ // ── The requesting person ────────────────────────────────────────────────
953
+
954
+ /**
955
+ * An isolated Oxy client authenticated as the person behind `source` — a
956
+ * request `middleware.auth()` resolved to a user session, or a bearer you
957
+ * already verified. Every `OxyServices` namespace, with no response cache,
958
+ * no token refresh and no fallback to this server's service identity; this
959
+ * server's own session is never touched.
960
+ *
961
+ * Prefer {@link withUser}, which disposes it for you. A client from here
962
+ * should be disposed when the request ends (`client.dispose()`) so a stray
963
+ * reference cannot keep using the bearer; one that is not holds no global
964
+ * resource and is collected normally.
965
+ *
966
+ * Throws `OxyAccessError` (`ACCESS_SESSION_REQUIRED`) for a request with no
967
+ * user session, including one authenticated by a service token.
968
+ */
969
+ asUser(source: OxyUserSource): OxyUserClient {
970
+ const config = userClientConfig(this.config);
971
+ return new OxyUserClient(
972
+ { ...config, baseURL: this.baseURL, cloudURL: this.cloudURL },
973
+ userBearerOf(source),
974
+ );
975
+ }
976
+
977
+ /**
978
+ * Run `use` with a client authenticated as the person behind `source`
979
+ * ({@link asUser}), and dispose it when `use` settles — success or failure.
980
+ *
981
+ * ```ts
982
+ * const accounts = await oxy.withUser(req, (user) => user.accounts.list());
983
+ * ```
984
+ */
985
+ async withUser<T>(
986
+ source: OxyUserSource,
987
+ use: (user: OxyUserClient) => T | Promise<T>,
988
+ ): Promise<T> {
989
+ const user = this.asUser(source);
990
+ try {
991
+ return await use(user);
992
+ } finally {
993
+ user.dispose();
994
+ }
995
+ }
996
+
997
+ /**
998
+ * What the person behind `req` may do in Oxy account `accountId` — an
999
+ * organization, store, channel or their own personal account: relationship,
1000
+ * role, source and the EFFECTIVE permissions (inheritance, grants and
1001
+ * revokes applied). Read with the request's own verified bearer.
1002
+ *
1003
+ * Cached per session for `appPermissionsCacheTtlMs` (30 s), positive
1004
+ * answers only; pass `{ fresh: true }` before a write. Throws
1005
+ * `OxyAccessError`: `ACCESS_SESSION_REQUIRED` (no session, a service token,
1006
+ * or Oxy answered 401), `ACCESS_DENIED` (Oxy answered 403/404 — no access,
1007
+ * or no such account), `ACCESS_UNAVAILABLE` (anything else; never a "no").
1008
+ * Map it with `accessErrorStatus`. Gate on `has(permission)`, never on
1009
+ * `role`.
1010
+ */
1011
+ accountAccessFor(
1012
+ req: OxyCallerRequest,
1013
+ accountId: string,
1014
+ options: CallerAccessOptions = {},
1015
+ ): Promise<OxyAccountAccess> {
1016
+ return this.callerAccess.account(req, accountId, options);
1017
+ }
1018
+
1019
+ /**
1020
+ * What the person behind `req` may do to Oxy application `applicationId`,
1021
+ * through their membership of the account that owns it (developer RBAC:
1022
+ * `app:read`, `app:update`, `webhooks:update`, `billing:manage`, …), plus
1023
+ * its `ownerAccountId` and whether it is `official`. Read with the
1024
+ * request's own verified bearer.
1025
+ *
1026
+ * Not {@link appPermissionsFor}: that one answers which of THIS backend's
1027
+ * own declared operator keys the person holds. Same cache, options and
1028
+ * errors as {@link accountAccessFor}; Oxy refuses (403) a caller with no
1029
+ * `app:read`, so a successful answer always includes it.
1030
+ */
1031
+ applicationAccessFor(
1032
+ req: OxyCallerRequest,
1033
+ applicationId: string,
1034
+ options: CallerAccessOptions = {},
1035
+ ): Promise<OxyApplicationAccess> {
1036
+ return this.callerAccess.application(req, applicationId, options);
918
1037
  }
919
1038
 
920
1039
  /**