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