@oxy.so/core 7.17.0 → 7.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cjs/HttpService.js +2 -0
  2. package/dist/cjs/OxyServices.errors.js +21 -0
  3. package/dist/cjs/OxyServices.js +86 -75
  4. package/dist/cjs/client/callOptions.js +66 -0
  5. package/dist/cjs/index.js +5 -4
  6. package/dist/cjs/server/OxyServer.js +10 -27
  7. package/dist/cjs/server/callerAccess.js +20 -1
  8. package/dist/cjs/server/namespaces.js +85 -26
  9. package/dist/cjs/server/requesterClient.js +1 -3
  10. package/dist/cjs/server/safeFetch.js +43 -12
  11. package/dist/esm/HttpService.js +2 -1
  12. package/dist/esm/OxyServices.errors.js +20 -0
  13. package/dist/esm/OxyServices.js +86 -75
  14. package/dist/esm/client/callOptions.js +61 -0
  15. package/dist/esm/index.js +1 -1
  16. package/dist/esm/server/OxyServer.js +10 -27
  17. package/dist/esm/server/callerAccess.js +20 -1
  18. package/dist/esm/server/namespaces.js +86 -27
  19. package/dist/esm/server/requesterClient.js +1 -3
  20. package/dist/esm/server/safeFetch.js +43 -12
  21. package/dist/types/HttpService.d.ts +2 -0
  22. package/dist/types/OxyServices.d.ts +38 -25
  23. package/dist/types/OxyServices.errors.d.ts +6 -0
  24. package/dist/types/client/callOptions.d.ts +45 -0
  25. package/dist/types/index.d.ts +4 -3
  26. package/dist/types/models/interfaces.d.ts +23 -0
  27. package/dist/types/server/OxyServer.d.ts +0 -9
  28. package/dist/types/server/callerAccess.d.ts +14 -1
  29. package/dist/types/server/namespaces.d.ts +50 -13
  30. package/dist/types/server/requesterClient.d.ts +0 -1
  31. package/dist/types/server/safeFetch.d.ts +16 -0
  32. package/package.json +1 -1
  33. package/src/HttpService.ts +2 -1
  34. package/src/OxyServices.errors.ts +19 -0
  35. package/src/OxyServices.ts +129 -75
  36. package/src/client/callOptions.ts +88 -0
  37. package/src/index.ts +12 -1
  38. package/src/models/interfaces.ts +25 -0
  39. package/src/server/OxyServer.ts +15 -33
  40. package/src/server/callerAccess.ts +34 -2
  41. package/src/server/namespaces.ts +128 -43
  42. package/src/server/requesterClient.ts +1 -3
  43. package/src/server/safeFetch.ts +69 -11
@@ -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
- if (!this._serverAccounts) this._serverAccounts = new ServerAccountsApi(this.context);
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
- if (!this._serverAssets) this._serverAssets = new ServerAssetsApi(this.context);
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
- if (!this._serverNotifications)
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
- if (!this._serverLinkedAccounts)
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
- if (!this._serverAgency) this._serverAgency = new ServerAgencyApi(this.context);
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
- if (!this._serverApps) this._serverApps = new ServerAppsApi(this.context);
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
- if (!this._serverReputation) this._serverReputation = new ServerReputationApi(this.context);
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
- if (!this._serverSafety) this._serverSafety = new ServerSafetyApi(this.context);
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
- if (!this._serverSession)
610
- this._serverSession = new ServerSessionApi(this.context, this.sessionValidationTtlMs, () =>
611
- this.revocationFeedEpoch(),
612
- );
613
- return this._serverSession;
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. */
@@ -33,6 +33,7 @@ import {
33
33
  type AccountKind,
34
34
  } from '@oxy.so/contracts';
35
35
  import type { AccountRelationship } from '../api/accounts';
36
+ import type { ApplicationStatus, ApplicationType } from '../api/apps';
36
37
  import {
37
38
  accessFailure,
38
39
  CallerAuthorityCache,
@@ -63,8 +64,20 @@ export interface OxyApplicationAccess {
63
64
  readonly applicationId: string;
64
65
  /** The account that owns the application; the access derives from the caller's membership there. */
65
66
  readonly ownerAccountId: string;
66
- /** Whether the application is an official Oxy application. */
67
+ /**
68
+ * Whether the application is one of Oxy's own: `isOfficial`, `isInternal`,
69
+ * or typed `first_party` / `internal` / `system` — the rule oxy-api applies
70
+ * to every trust decision (`isTrustedApplication`). All of those fields are
71
+ * staff-controlled; a self-service `third_party` application is never
72
+ * official, whatever its owner sets.
73
+ */
67
74
  readonly official: boolean;
75
+ /**
76
+ * The application's lifecycle status. A `suspended` or `pending_review`
77
+ * application is still answered (its members may read it); whether it may
78
+ * act is the caller's decision.
79
+ */
80
+ readonly status: ApplicationStatus;
68
81
  /** Informational; gate on `permissions` / `has`, never on the role name. */
69
82
  readonly role: string;
70
83
  /** The application permissions (`app:read`, `app:update`, `webhooks:update`, …), sorted. */
@@ -117,6 +130,19 @@ function stringArray(value: unknown): string[] | null {
117
130
  }
118
131
 
119
132
  const RELATIONSHIPS: readonly AccountRelationship[] = ['self', 'owner', 'member'];
133
+ const APPLICATION_TYPES: readonly ApplicationType[] = [
134
+ 'first_party',
135
+ 'third_party',
136
+ 'internal',
137
+ 'system',
138
+ ];
139
+ const OFFICIAL_TYPES: readonly ApplicationType[] = ['first_party', 'internal', 'system'];
140
+ /** `deleted` is absent on purpose: Oxy answers a deleted application 404. */
141
+ const APPLICATION_STATUSES: readonly ApplicationStatus[] = [
142
+ 'active',
143
+ 'suspended',
144
+ 'pending_review',
145
+ ];
120
146
 
121
147
  /** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
122
148
  export function parseAccountAccess(accountId: string, answer: unknown): OxyAccountAccess | null {
@@ -144,12 +170,17 @@ export function parseApplicationAccess(
144
170
  const application = record(answer);
145
171
  if (!application || application._id !== applicationId) return null;
146
172
  const ownerAccountId = application.ownerAccountId;
173
+ const type = application.type as ApplicationType;
174
+ const status = application.status as ApplicationStatus;
147
175
  const membership = record(application.callerMembership);
148
176
  const permissions = stringArray(membership?.permissions);
149
177
  if (
150
178
  typeof ownerAccountId !== 'string' ||
151
179
  ownerAccountId === '' ||
152
180
  typeof application.isOfficial !== 'boolean' ||
181
+ typeof application.isInternal !== 'boolean' ||
182
+ !APPLICATION_TYPES.includes(type) ||
183
+ !APPLICATION_STATUSES.includes(status) ||
153
184
  !membership ||
154
185
  typeof membership.role !== 'string' ||
155
186
  membership.role === '' ||
@@ -161,7 +192,8 @@ export function parseApplicationAccess(
161
192
  return Object.freeze({
162
193
  applicationId,
163
194
  ownerAccountId,
164
- official: application.isOfficial,
195
+ official: application.isOfficial || application.isInternal || OFFICIAL_TYPES.includes(type),
196
+ status,
165
197
  role: membership.role,
166
198
  ...permissionSet(permissions),
167
199
  });
@@ -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, 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.
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 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
- }
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 lane(this.ctx).request<{ results: ServiceFileReferenceResult[] }>(
557
- method,
558
- '/assets/service/references',
559
- { references: chunk },
560
- { ...serviceCallRequestOptions(options), cache: false },
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 = retryAfterSeconds(error);
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
- if (!this.requesterAssets) this.requesterAssets = new ServerAssetsApi(this.context);
26
- return this.requesterAssets;
24
+ return this.namespace('assets', (context) => new ServerAssetsApi(context));
27
25
  }
28
26
  }
@@ -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 { ok: true, ip: literalHost, family: literalFamily === 4 ? 4 : 6 };
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
- // The lookup shares the headers deadline and the caller's signal.
510
- const guard = await assertSafePublicUrl(currentUrl, {
511
- lookupTimeoutMs: headersTimeoutMs,
512
- signal,
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)