@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.
- package/README.md +24 -13
- package/dist/cjs/server/OxyServer.js +85 -3
- package/dist/cjs/server/appPermissions.js +14 -92
- package/dist/cjs/server/callerAccess.js +170 -0
- package/dist/cjs/server/callerAuthority.js +206 -0
- package/dist/cjs/server/capabilityTicket.js +39 -6
- package/dist/cjs/server/cors.js +41 -2
- package/dist/cjs/server/index.js +33 -5
- package/dist/cjs/server/liveCapabilityTicket.js +4 -2
- package/dist/cjs/server/middleware.js +104 -2
- package/dist/cjs/server/namespaces.js +15 -4
- package/dist/cjs/server/safeFetch.js +29 -1
- package/dist/cjs/server/userClient.js +127 -0
- package/dist/cjs/utils/cache.js +58 -22
- package/dist/esm/server/OxyServer.js +85 -3
- package/dist/esm/server/appPermissions.js +12 -89
- package/dist/esm/server/callerAccess.js +164 -0
- package/dist/esm/server/callerAuthority.js +194 -0
- package/dist/esm/server/capabilityTicket.js +38 -6
- package/dist/esm/server/cors.js +39 -2
- package/dist/esm/server/index.js +26 -3
- package/dist/esm/server/liveCapabilityTicket.js +5 -3
- package/dist/esm/server/middleware.js +104 -2
- package/dist/esm/server/namespaces.js +15 -4
- package/dist/esm/server/safeFetch.js +29 -1
- package/dist/esm/server/userClient.js +121 -0
- package/dist/esm/utils/cache.js +57 -22
- package/dist/types/api/accounts.d.ts +8 -1
- package/dist/types/api/apps.d.ts +22 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +74 -7
- package/dist/types/server/appPermissions.d.ts +3 -13
- package/dist/types/server/auth.d.ts +5 -0
- package/dist/types/server/callerAccess.d.ts +99 -0
- package/dist/types/server/callerAuthority.d.ts +127 -0
- package/dist/types/server/capabilityTicket.d.ts +23 -1
- package/dist/types/server/cors.d.ts +39 -0
- package/dist/types/server/index.d.ts +31 -7
- package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
- package/dist/types/server/middleware.d.ts +34 -2
- package/dist/types/server/namespaces.d.ts +18 -2
- package/dist/types/server/safeFetch.d.ts +10 -0
- package/dist/types/server/userClient.d.ts +71 -0
- package/dist/types/utils/cache.d.ts +13 -0
- package/package.json +1 -1
- package/src/api/accounts.ts +8 -1
- package/src/api/apps.ts +23 -4
- package/src/index.ts +1 -0
- package/src/server/OxyServer.ts +126 -7
- package/src/server/appPermissions.ts +28 -105
- package/src/server/auth.ts +5 -0
- package/src/server/callerAccess.ts +264 -0
- package/src/server/callerAuthority.ts +267 -0
- package/src/server/capabilityTicket.ts +76 -17
- package/src/server/cors.ts +63 -2
- package/src/server/index.ts +38 -4
- package/src/server/liveCapabilityTicket.ts +12 -5
- package/src/server/middleware.ts +171 -4
- package/src/server/namespaces.ts +36 -5
- package/src/server/safeFetch.ts +31 -1
- package/src/server/userClient.ts +147 -0
- 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
|
-
*
|
|
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
package/src/api/accounts.ts
CHANGED
|
@@ -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
|
|
127
|
-
* inherited), embedded by the API on
|
|
128
|
-
* caller has no membership. Use
|
|
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
package/src/server/OxyServer.ts
CHANGED
|
@@ -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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
|
913
|
-
*
|
|
914
|
-
*
|
|
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
|
/**
|