@oxy.so/core 7.18.0 → 7.19.1
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 +5 -2
- package/dist/cjs/HttpService.js +2 -0
- package/dist/cjs/OxyServices.errors.js +21 -0
- package/dist/cjs/OxyServices.js +86 -75
- package/dist/cjs/client/callOptions.js +66 -0
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/server/OxyServer.js +10 -27
- package/dist/cjs/server/appPermissions.js +7 -4
- package/dist/cjs/server/namespaces.js +85 -26
- package/dist/cjs/server/requesterClient.js +1 -3
- package/dist/cjs/server/safeFetch.js +43 -12
- package/dist/esm/HttpService.js +2 -1
- package/dist/esm/OxyServices.errors.js +20 -0
- package/dist/esm/OxyServices.js +86 -75
- package/dist/esm/client/callOptions.js +61 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server/OxyServer.js +10 -27
- package/dist/esm/server/appPermissions.js +7 -4
- package/dist/esm/server/namespaces.js +86 -27
- package/dist/esm/server/requesterClient.js +1 -3
- package/dist/esm/server/safeFetch.js +43 -12
- package/dist/types/HttpService.d.ts +2 -0
- package/dist/types/OxyServices.d.ts +38 -25
- package/dist/types/OxyServices.errors.d.ts +6 -0
- package/dist/types/client/callOptions.d.ts +45 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/server/OxyServer.d.ts +0 -9
- package/dist/types/server/appPermissions.d.ts +7 -4
- package/dist/types/server/namespaces.d.ts +50 -13
- package/dist/types/server/requesterClient.d.ts +0 -1
- package/dist/types/server/safeFetch.d.ts +16 -0
- package/package.json +2 -2
- package/src/HttpService.ts +2 -1
- package/src/OxyServices.errors.ts +19 -0
- package/src/OxyServices.ts +129 -75
- package/src/client/callOptions.ts +88 -0
- package/src/index.ts +12 -1
- package/src/models/interfaces.ts +25 -0
- package/src/server/OxyServer.ts +15 -33
- package/src/server/appPermissions.ts +7 -4
- package/src/server/namespaces.ts +128 -43
- package/src/server/requesterClient.ts +1 -3
- package/src/server/safeFetch.ts +69 -11
package/src/server/OxyServer.ts
CHANGED
|
@@ -552,65 +552,47 @@ export class OxyServer extends OxyServices {
|
|
|
552
552
|
|
|
553
553
|
// ── Server-side namespaces ───────────────────────────────────────────────
|
|
554
554
|
|
|
555
|
-
private _serverAccounts?: ServerAccountsApi;
|
|
556
|
-
private _serverAssets?: ServerAssetsApi;
|
|
557
|
-
private _serverNotifications?: ServerNotificationsApi;
|
|
558
|
-
private _serverLinkedAccounts?: ServerLinkedAccountsApi;
|
|
559
|
-
private _serverAgency?: ServerAgencyApi;
|
|
560
|
-
private _serverApps?: ServerAppsApi;
|
|
561
|
-
private _serverReputation?: ServerReputationApi;
|
|
562
|
-
private _serverSafety?: ServerSafetyApi;
|
|
563
|
-
private _serverSession?: ServerSessionApi;
|
|
564
|
-
|
|
565
555
|
/** The account graph, plus the service-only `accessFor` membership verdict. */
|
|
566
556
|
override get accounts(): ServerAccountsApi {
|
|
567
|
-
|
|
568
|
-
return this._serverAccounts;
|
|
557
|
+
return this.namespace('accounts', (context) => new ServerAccountsApi(context));
|
|
569
558
|
}
|
|
570
559
|
/** Files, plus the service-only metadata and linked-URL lookups. */
|
|
571
560
|
override get assets(): ServerAssetsApi {
|
|
572
|
-
|
|
573
|
-
return this._serverAssets;
|
|
561
|
+
return this.namespace('assets', (context) => new ServerAssetsApi(context));
|
|
574
562
|
}
|
|
575
563
|
/** The inbox and push tokens, plus `create`. */
|
|
576
564
|
override get notifications(): ServerNotificationsApi {
|
|
577
|
-
|
|
578
|
-
this._serverNotifications = new ServerNotificationsApi(this.context);
|
|
579
|
-
return this._serverNotifications;
|
|
565
|
+
return this.namespace('notifications', (context) => new ServerNotificationsApi(context));
|
|
580
566
|
}
|
|
581
567
|
/** Linked external accounts, plus `forUser`. */
|
|
582
568
|
override get linkedAccounts(): ServerLinkedAccountsApi {
|
|
583
|
-
|
|
584
|
-
this._serverLinkedAccounts = new ServerLinkedAccountsApi(this.context);
|
|
585
|
-
return this._serverLinkedAccounts;
|
|
569
|
+
return this.namespace('linkedAccounts', (context) => new ServerLinkedAccountsApi(context));
|
|
586
570
|
}
|
|
587
571
|
/** Delegated capabilities, with the service-only authority calls. */
|
|
588
572
|
override get agency(): ServerAgencyApi {
|
|
589
|
-
|
|
590
|
-
return this._serverAgency;
|
|
573
|
+
return this.namespace('agency', (context) => new ServerAgencyApi(context));
|
|
591
574
|
}
|
|
592
575
|
/** Application management, plus the receiver-bound Alia machine-key lookup. */
|
|
593
576
|
override get apps(): ServerAppsApi {
|
|
594
|
-
|
|
595
|
-
return this._serverApps;
|
|
577
|
+
return this.namespace('apps', (context) => new ServerAppsApi(context));
|
|
596
578
|
}
|
|
597
579
|
/** Reputation reads, plus `award`. */
|
|
598
580
|
override get reputation(): ServerReputationApi {
|
|
599
|
-
|
|
600
|
-
return this._serverReputation;
|
|
581
|
+
return this.namespace('reputation', (context) => new ServerReputationApi(context));
|
|
601
582
|
}
|
|
602
583
|
/** Policy reads, plus the consumer-service decision lanes (ADR 0041). */
|
|
603
584
|
override get safety(): ServerSafetyApi {
|
|
604
|
-
|
|
605
|
-
return this._serverSafety;
|
|
585
|
+
return this.namespace('safety', (context) => new ServerSafetyApi(context));
|
|
606
586
|
}
|
|
607
587
|
/** Session state, plus validation through a reviewed supervised action. */
|
|
608
588
|
override get session(): ServerSessionApi {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
589
|
+
return this.namespace(
|
|
590
|
+
'session',
|
|
591
|
+
(context) =>
|
|
592
|
+
new ServerSessionApi(context, this.sessionValidationTtlMs, () =>
|
|
593
|
+
this.revocationFeedEpoch(),
|
|
594
|
+
),
|
|
595
|
+
);
|
|
614
596
|
}
|
|
615
597
|
|
|
616
598
|
/** Close the revocation feed along with the HTTP client. */
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Application roles, from the application's side: which of THIS application's
|
|
3
3
|
* declared permissions does the person behind a request hold?
|
|
4
4
|
*
|
|
5
|
-
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
|
|
6
|
+
* and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
|
|
7
|
+
* in its environment. One of Oxy's own applications declares its keys
|
|
8
|
+
* (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
|
|
9
|
+
* the owning organization (ADR 0056); a third-party app declares them in the
|
|
10
|
+
* Console and its owners grant them to people. The answer has the same shape
|
|
11
|
+
* either way.
|
|
9
12
|
*
|
|
10
13
|
* ```ts
|
|
11
14
|
* const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
|
package/src/server/namespaces.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type AccountAccessVerdict,
|
|
36
36
|
} from '@oxy.so/contracts';
|
|
37
37
|
import type { RequestOptions } from '../HttpService';
|
|
38
|
+
import { type CallOptions, sendWithCallOptions } from '../client/callOptions';
|
|
38
39
|
import type { OxyContext, ServiceLane } from '../client/context';
|
|
39
40
|
import type {
|
|
40
41
|
ApplicationFile,
|
|
@@ -65,6 +66,10 @@ import type {
|
|
|
65
66
|
ServiceReferenceReconcileResult,
|
|
66
67
|
ServiceUserMediaFile,
|
|
67
68
|
ServiceUserMediaImport,
|
|
69
|
+
ServiceMediaCacheUpload,
|
|
70
|
+
ServiceFederatedMediaUpload,
|
|
71
|
+
ServiceFederatedMediaDeletion,
|
|
72
|
+
ServiceFederatedMediaDeleteResult,
|
|
68
73
|
} from '../models/interfaces';
|
|
69
74
|
import { bindRequesterLane, type RequesterProof } from './requesterContext';
|
|
70
75
|
import { AssetsApi } from '../api/assets';
|
|
@@ -82,7 +87,7 @@ import {
|
|
|
82
87
|
waitForImport,
|
|
83
88
|
} from './assetJobs';
|
|
84
89
|
import { NotificationsApi } from '../api/notifications';
|
|
85
|
-
import { OxyApiError } from '../OxyServices.errors';
|
|
90
|
+
import { OxyApiError, retryAfterSecondsOf } from '../OxyServices.errors';
|
|
86
91
|
import { LinkedAccountsApi } from '../api/linkedAccounts';
|
|
87
92
|
import {
|
|
88
93
|
AgencyApi,
|
|
@@ -109,6 +114,14 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
|
109
114
|
const SERVICE_LINKED_DOWNLOAD_URL_CHUNK_SIZE = 25;
|
|
110
115
|
/** Server-side cap of `POST`/`DELETE /assets/service/references`. */
|
|
111
116
|
const SERVICE_REFERENCES_CHUNK_SIZE = 100;
|
|
117
|
+
/** Server-side cap of `POST /assets/service/federation/delete`. */
|
|
118
|
+
const SERVICE_FEDERATED_DELETE_CHUNK_SIZE = 20;
|
|
119
|
+
const FEDERATED_DELETE_RESULTS: ReadonlySet<unknown> = new Set<ServiceFederatedMediaDeleteResult>([
|
|
120
|
+
'deleted',
|
|
121
|
+
'not_found',
|
|
122
|
+
'in_use',
|
|
123
|
+
'forbidden',
|
|
124
|
+
]);
|
|
112
125
|
/** Server-side cap of `POST /assets/service/app/import/batch`. */
|
|
113
126
|
const SERVICE_IMPORT_BATCH_SIZE = 50;
|
|
114
127
|
/** An import fetches a remote source (up to 60 s server-side): a longer per-attempt timeout. */
|
|
@@ -141,30 +154,14 @@ const HEADER_SAFE_PATTERN = /^[\x20-\x7e]*$/;
|
|
|
141
154
|
/** The bytes of a streamed service upload: sent as the whole request body. */
|
|
142
155
|
export type ServiceUploadBody = Uint8Array | ArrayBuffer | Blob;
|
|
143
156
|
|
|
144
|
-
/** What `uploadAppFile` and `uploadUserMedia` may say about the bytes besides their type. */
|
|
145
157
|
/**
|
|
146
|
-
* Per-call transport options for a service call
|
|
147
|
-
* `
|
|
148
|
-
*
|
|
158
|
+
* Per-call transport options for a service call — the {@link CallOptions}
|
|
159
|
+
* `withCallOptions` takes, accepted directly by the methods an outbox calls
|
|
160
|
+
* (`references.add` / `remove`).
|
|
149
161
|
*/
|
|
150
|
-
export
|
|
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
|
-
}
|
|
162
|
+
export type ServiceCallOptions = CallOptions;
|
|
167
163
|
|
|
164
|
+
/** What `uploadAppFile` and `uploadUserMedia` may say about the bytes besides their type. */
|
|
168
165
|
export interface ServiceUploadDescription {
|
|
169
166
|
/**
|
|
170
167
|
* Stored as the file's original name. Sent as a header, so only printable
|
|
@@ -366,6 +363,107 @@ export class ServerAssetsApi extends AssetsApi {
|
|
|
366
363
|
this.reconcileReferences(entityType, items, options),
|
|
367
364
|
};
|
|
368
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The federation media cache (`/assets/service/cache`): copies of remote
|
|
368
|
+
* media this application serves from Oxy's CDN instead of the remote host,
|
|
369
|
+
* in a reserved namespace that only holds what may be evicted. Every file
|
|
370
|
+
* is public by construction (`https://cloud.oxy.so/<id>`, `publicUrl`).
|
|
371
|
+
*
|
|
372
|
+
* - `upload` (`files:write`): the bytes as the whole body — a Blob from
|
|
373
|
+
* `fs.openAsBlob(path)` streams a file from disk; image, video or audio
|
|
374
|
+
* only (415), at most Oxy's cache size (413). Sent once.
|
|
375
|
+
* - `delete` (`federation:write`): evict one cache object. 404 when it is
|
|
376
|
+
* gone, 403 for anything outside the cache namespace — a service token can
|
|
377
|
+
* never delete media a person owns.
|
|
378
|
+
*
|
|
379
|
+
* A refusal for this application's media-write budget is a 429 whose
|
|
380
|
+
* `retryAfterSeconds` ({@link retryAfterSecondsOf}) says when to write again.
|
|
381
|
+
*/
|
|
382
|
+
readonly mediaCache = {
|
|
383
|
+
upload: (
|
|
384
|
+
bytes: ServiceUploadBody,
|
|
385
|
+
mime: string,
|
|
386
|
+
options: ServiceUploadDescription = {},
|
|
387
|
+
): Promise<ServiceMediaCacheUpload> =>
|
|
388
|
+
lane(this.ctx).request<ServiceMediaCacheUpload>('POST', '/assets/service/cache', bytes, {
|
|
389
|
+
headers: uploadHeaders(mime, options),
|
|
390
|
+
cache: false,
|
|
391
|
+
retry: false,
|
|
392
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
393
|
+
...(options.timeout ? { timeout: options.timeout } : {}),
|
|
394
|
+
}),
|
|
395
|
+
delete: async (fileId: string): Promise<void> => {
|
|
396
|
+
await lane(this.ctx).request<unknown>(
|
|
397
|
+
'DELETE',
|
|
398
|
+
`/assets/service/cache/${encodeURIComponent(fileId)}`,
|
|
399
|
+
undefined,
|
|
400
|
+
{ cache: false },
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Durable federated media (`/assets/service/federation`): a remote post's
|
|
407
|
+
* media re-hosted as a normal public file owned by the federated Oxy user
|
|
408
|
+
* it came from — outside the cache namespace, so a stored post may
|
|
409
|
+
* reference it for good.
|
|
410
|
+
*
|
|
411
|
+
* - `upload` (`files:write`): `ownerUserId` must be an existing federated
|
|
412
|
+
* user (403). Idempotent per (owner, application, bytes): the existing
|
|
413
|
+
* file with `deduplicated: true`, which other posts may share — check
|
|
414
|
+
* your own references before deleting it. Bytes another owner or
|
|
415
|
+
* application holds are a 409 `FEDERATED_MEDIA_OWNED_ELSEWHERE`. Sent once.
|
|
416
|
+
* - `delete` (`files:write` + `federation:write`): one answer per distinct
|
|
417
|
+
* id, in order, sent 20 at a time — only federated media this application
|
|
418
|
+
* uploaded is ever deleted (`forbidden` otherwise), and bytes another
|
|
419
|
+
* account holds are kept (`in_use`). Every step is idempotent, so a failed
|
|
420
|
+
* call is retried whole.
|
|
421
|
+
*
|
|
422
|
+
* Budget refusals are 429s, as for {@link mediaCache}.
|
|
423
|
+
*/
|
|
424
|
+
readonly federatedMedia = {
|
|
425
|
+
upload: async (
|
|
426
|
+
ownerUserId: string,
|
|
427
|
+
bytes: ServiceUploadBody,
|
|
428
|
+
mime: string,
|
|
429
|
+
options: ServiceUploadDescription = {},
|
|
430
|
+
): Promise<ServiceFederatedMediaUpload> => {
|
|
431
|
+
const res = await lane(this.ctx).request<ServiceFederatedMediaUpload>(
|
|
432
|
+
'POST',
|
|
433
|
+
'/assets/service/federation',
|
|
434
|
+
bytes,
|
|
435
|
+
{
|
|
436
|
+
headers: { ...uploadHeaders(mime, options), 'x-owner-user-id': ownerUserId },
|
|
437
|
+
cache: false,
|
|
438
|
+
retry: false,
|
|
439
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
440
|
+
...(options.timeout ? { timeout: options.timeout } : {}),
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
return { file: res.file, deduplicated: res.deduplicated === true };
|
|
444
|
+
},
|
|
445
|
+
delete: async (fileIds: string[]): Promise<ServiceFederatedMediaDeletion[]> => {
|
|
446
|
+
const results: ServiceFederatedMediaDeletion[] = [];
|
|
447
|
+
for (const part of chunk(uniqueIds(fileIds), SERVICE_FEDERATED_DELETE_CHUNK_SIZE)) {
|
|
448
|
+
const res = await lane(this.ctx).request<{ results: ServiceFederatedMediaDeletion[] }>(
|
|
449
|
+
'POST',
|
|
450
|
+
'/assets/service/federation/delete',
|
|
451
|
+
{ ids: part },
|
|
452
|
+
{ cache: false },
|
|
453
|
+
);
|
|
454
|
+
const asked = new Set(part);
|
|
455
|
+
for (const entry of Array.isArray(res?.results) ? res.results : []) {
|
|
456
|
+
// An id not asked, or a result this build does not know, is no
|
|
457
|
+
// answer: the caller sees the id unanswered and retries it.
|
|
458
|
+
if (asked.has(entry?.id) && FEDERATED_DELETE_RESULTS.has(entry?.result)) {
|
|
459
|
+
results.push({ id: entry.id, result: entry.result });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return results;
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
369
467
|
/**
|
|
370
468
|
* Per-play, revocable streaming access (ADR 0043 stage 4c). A play session
|
|
371
469
|
* is ONE file's HLS ladder (a video's, or an audio file's audio-only one)
|
|
@@ -553,11 +651,13 @@ export class ServerAssetsApi extends AssetsApi {
|
|
|
553
651
|
const chunk = references
|
|
554
652
|
.slice(start, start + SERVICE_REFERENCES_CHUNK_SIZE)
|
|
555
653
|
.map(({ fileId, entityType, entityId }) => ({ fileId, entityType, entityId }));
|
|
556
|
-
const res = await
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
654
|
+
const res = await sendWithCallOptions({ cache: false }, options, (requestOptions) =>
|
|
655
|
+
lane(this.ctx).request<{ results: ServiceFileReferenceResult[] }>(
|
|
656
|
+
method,
|
|
657
|
+
'/assets/service/references',
|
|
658
|
+
{ references: chunk },
|
|
659
|
+
requestOptions,
|
|
660
|
+
),
|
|
561
661
|
);
|
|
562
662
|
results.push(...res.results);
|
|
563
663
|
}
|
|
@@ -1666,27 +1766,12 @@ async function waitingOutRateLimits<T>(send: () => Promise<T>): Promise<T> {
|
|
|
1666
1766
|
|
|
1667
1767
|
/** How long to wait after a refusal: what Oxy said, else the doubling fallback; capped. */
|
|
1668
1768
|
function rateLimitWaitMs(error: unknown, refusals: number): number {
|
|
1669
|
-
const said =
|
|
1769
|
+
const said = retryAfterSecondsOf(error);
|
|
1670
1770
|
const wait =
|
|
1671
1771
|
said !== undefined ? said * 1000 : SERVICE_RECONCILE_RATE_LIMIT_FALLBACK_MS * 2 ** refusals;
|
|
1672
1772
|
return Math.min(wait, SERVICE_RECONCILE_RATE_LIMIT_MAX_WAIT_MS);
|
|
1673
1773
|
}
|
|
1674
1774
|
|
|
1675
|
-
/** `details.retryAfterSeconds` of a refusal (`RateLimitedError` on the server), if any. */
|
|
1676
|
-
function retryAfterSeconds(error: unknown): number | undefined {
|
|
1677
|
-
if (!error || typeof error !== 'object') return undefined;
|
|
1678
|
-
const candidates = [
|
|
1679
|
-
(error as { details?: { retryAfterSeconds?: unknown } }).details,
|
|
1680
|
-
(error as { response?: { data?: { details?: { retryAfterSeconds?: unknown } } } }).response
|
|
1681
|
-
?.data?.details,
|
|
1682
|
-
];
|
|
1683
|
-
for (const details of candidates) {
|
|
1684
|
-
const seconds = details?.retryAfterSeconds;
|
|
1685
|
-
if (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0) return seconds;
|
|
1686
|
-
}
|
|
1687
|
-
return undefined;
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
1775
|
async function waitForCommit(
|
|
1691
1776
|
service: ServiceLane,
|
|
1692
1777
|
path: string,
|
|
@@ -7,7 +7,6 @@ import { bindRequesterLane, type RequesterProof } from './requesterContext';
|
|
|
7
7
|
/** Oxy namespaces bound to one verified requester, without sharing mutable auth state. */
|
|
8
8
|
export class OxyRequesterClient extends OxyServices {
|
|
9
9
|
private readonly requesterLane: ServiceLane;
|
|
10
|
-
private requesterAssets?: ServerAssetsApi;
|
|
11
10
|
constructor(baseURL: string, cloudURL: string, original: ServiceLane, proof: RequesterProof) {
|
|
12
11
|
super({ baseURL, cloudURL });
|
|
13
12
|
this.requesterLane = bindRequesterLane(original, proof);
|
|
@@ -22,7 +21,6 @@ export class OxyRequesterClient extends OxyServices {
|
|
|
22
21
|
return this.requesterLane.request(method, url, data, options);
|
|
23
22
|
}
|
|
24
23
|
override get assets(): ServerAssetsApi {
|
|
25
|
-
|
|
26
|
-
return this.requesterAssets;
|
|
24
|
+
return this.namespace('assets', (context) => new ServerAssetsApi(context));
|
|
27
25
|
}
|
|
28
26
|
}
|
package/src/server/safeFetch.ts
CHANGED
|
@@ -163,6 +163,30 @@ export interface SsrfCheckFail {
|
|
|
163
163
|
|
|
164
164
|
export type SsrfCheckResult = SsrfCheckOk | SsrfCheckFail;
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Every successful check {@link assertSafePublicUrl} returned, and the exact
|
|
168
|
+
* URL it checked. `safeFetch({ validated })` accepts only these, for that URL:
|
|
169
|
+
* a hand-built `{ ok: true, ip }`, or a check of another URL, never pins a
|
|
170
|
+
* connection.
|
|
171
|
+
*/
|
|
172
|
+
const issuedChecks = new WeakMap<SsrfCheckOk, string>();
|
|
173
|
+
|
|
174
|
+
/** `rawUrl`'s normalized form, or `null` when it is not a URL. */
|
|
175
|
+
function normalizedUrl(rawUrl: string): string | null {
|
|
176
|
+
try {
|
|
177
|
+
return new URL(rawUrl).href;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A successful check of `href`, registered as one this module issued. */
|
|
184
|
+
function issueCheck(href: string, ip: string, family: 4 | 6): SsrfCheckOk {
|
|
185
|
+
const check: SsrfCheckOk = { ok: true, ip, family };
|
|
186
|
+
issuedChecks.set(check, href);
|
|
187
|
+
return check;
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
/**
|
|
167
191
|
* Return true if a literal IP address is private/loopback/link-local/reserved/
|
|
168
192
|
* multicast/metadata and therefore must NOT be contacted.
|
|
@@ -256,7 +280,7 @@ export async function assertSafePublicUrl(
|
|
|
256
280
|
if (isBlockedIp(literalHost)) {
|
|
257
281
|
return { ok: false, reason: 'literal ip in blocked range' };
|
|
258
282
|
}
|
|
259
|
-
return
|
|
283
|
+
return issueCheck(parsed.href, literalHost, literalFamily === 4 ? 4 : 6);
|
|
260
284
|
}
|
|
261
285
|
|
|
262
286
|
// Reject ambiguous numeric host forms BEFORE touching the resolver. `isIP`
|
|
@@ -301,11 +325,7 @@ export async function assertSafePublicUrl(
|
|
|
301
325
|
|
|
302
326
|
// All records are public. Pin the connection to the first one.
|
|
303
327
|
const chosen = records[0];
|
|
304
|
-
return
|
|
305
|
-
ok: true,
|
|
306
|
-
ip: chosen.address,
|
|
307
|
-
family: chosen.family === 4 ? 4 : 6,
|
|
308
|
-
};
|
|
328
|
+
return issueCheck(parsed.href, chosen.address, chosen.family === 4 ? 4 : 6);
|
|
309
329
|
}
|
|
310
330
|
|
|
311
331
|
/** What a refusal of a first-party Oxy host adds to its reason. */
|
|
@@ -372,6 +392,22 @@ export interface SafeFetchOptions {
|
|
|
372
392
|
* destroyed.
|
|
373
393
|
*/
|
|
374
394
|
signal?: AbortSignal;
|
|
395
|
+
/**
|
|
396
|
+
* The check of this very URL the caller already made with
|
|
397
|
+
* {@link assertSafePublicUrl} (to refuse a URL before it reaches a cache, a
|
|
398
|
+
* queue or a database, say): the first hop connects to the address it
|
|
399
|
+
* validated instead of resolving the name again. Only a check
|
|
400
|
+
* `assertSafePublicUrl` returned for exactly `rawUrl` is accepted; anything
|
|
401
|
+
* else is a programming error and throws a `TypeError` before any request.
|
|
402
|
+
* Every redirect hop is still resolved and checked afresh.
|
|
403
|
+
*/
|
|
404
|
+
validated?: SsrfCheckOk;
|
|
405
|
+
/**
|
|
406
|
+
* Refuse ({@link SsrfRejection}) any hop that is not https — the first, and
|
|
407
|
+
* every redirect — so no request of the chain is sent in the clear. Without
|
|
408
|
+
* it, http and https are both allowed on every hop.
|
|
409
|
+
*/
|
|
410
|
+
requireHttps?: boolean;
|
|
375
411
|
}
|
|
376
412
|
|
|
377
413
|
/**
|
|
@@ -485,8 +521,19 @@ export async function safeFetch(
|
|
|
485
521
|
redirect = 'follow',
|
|
486
522
|
headersTimeoutMs = UPSTREAM_HEADERS_TIMEOUT_MS,
|
|
487
523
|
signal,
|
|
524
|
+
validated,
|
|
525
|
+
requireHttps = false,
|
|
488
526
|
} = options;
|
|
489
527
|
|
|
528
|
+
if (validated !== undefined) {
|
|
529
|
+
const checked = issuedChecks.get(validated);
|
|
530
|
+
if (checked === undefined || checked !== normalizedUrl(rawUrl)) {
|
|
531
|
+
throw new TypeError(
|
|
532
|
+
'safeFetch: `validated` must be the check assertSafePublicUrl returned for this exact URL',
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
490
537
|
// Normalize a case-insensitive header map and ensure a User-Agent default.
|
|
491
538
|
const baseHeaders: Record<string, string> = {};
|
|
492
539
|
if (callerHeaders) {
|
|
@@ -506,11 +553,22 @@ export async function safeFetch(
|
|
|
506
553
|
throw new UpstreamError('request aborted');
|
|
507
554
|
}
|
|
508
555
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
556
|
+
if (requireHttps && !normalizedUrl(currentUrl)?.startsWith('https:')) {
|
|
557
|
+
throw new SsrfRejection(
|
|
558
|
+
hop === 0 ? 'https required' : 'https required: redirect to a non-https url',
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Hop 0 may reuse the caller's check of this exact URL; a redirect hop
|
|
563
|
+
// targets a new URL and is always checked here. The lookup shares the
|
|
564
|
+
// headers deadline and the caller's signal.
|
|
565
|
+
const guard =
|
|
566
|
+
hop === 0 && validated
|
|
567
|
+
? validated
|
|
568
|
+
: await assertSafePublicUrl(currentUrl, {
|
|
569
|
+
lookupTimeoutMs: headersTimeoutMs,
|
|
570
|
+
signal,
|
|
571
|
+
});
|
|
514
572
|
if (!guard.ok) {
|
|
515
573
|
throw guard.incomplete
|
|
516
574
|
? new UpstreamError(guard.reason)
|