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