@adcp/sdk 13.0.0-rc.8 → 13.0.0-rc.9
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/dist/lib/adapters/roster-account-store.d.mts +9 -8
- package/dist/lib/adapters/roster-account-store.d.ts +9 -8
- package/dist/lib/adapters/roster-account-store.d.ts.map +1 -1
- package/dist/lib/adapters/roster-account-store.js +2 -1
- package/dist/lib/adapters/roster-account-store.js.map +1 -1
- package/dist/lib/adapters/roster-account-store.mjs +2 -1
- package/dist/lib/adapters/roster-account-store.mjs.map +1 -1
- package/dist/lib/net/address-guards.d.ts.map +1 -1
- package/dist/lib/net/address-guards.js +6 -0
- package/dist/lib/net/address-guards.js.map +1 -1
- package/dist/lib/net/address-guards.mjs +6 -0
- package/dist/lib/net/address-guards.mjs.map +1 -1
- package/dist/lib/net/ssrf-fetch.d.ts.map +1 -1
- package/dist/lib/net/ssrf-fetch.js +66 -53
- package/dist/lib/net/ssrf-fetch.js.map +1 -1
- package/dist/lib/net/ssrf-fetch.mjs +66 -53
- package/dist/lib/net/ssrf-fetch.mjs.map +1 -1
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/account-mode.d.mts +2 -1
- package/dist/lib/server/account-mode.d.ts +2 -1
- package/dist/lib/server/account-mode.d.ts.map +1 -1
- package/dist/lib/server/account-mode.js +1 -0
- package/dist/lib/server/account-mode.js.map +1 -1
- package/dist/lib/server/account-mode.mjs +1 -0
- package/dist/lib/server/account-mode.mjs.map +1 -1
- package/dist/lib/server/create-adcp-server.d.mts +10 -6
- package/dist/lib/server/create-adcp-server.d.ts +10 -6
- package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
- package/dist/lib/server/create-adcp-server.js +2 -1
- package/dist/lib/server/create-adcp-server.js.map +1 -1
- package/dist/lib/server/create-adcp-server.mjs +2 -1
- package/dist/lib/server/create-adcp-server.mjs.map +1 -1
- package/dist/lib/server/decisioning/account.d.mts +16 -11
- package/dist/lib/server/decisioning/account.d.ts +16 -11
- package/dist/lib/server/decisioning/account.d.ts.map +1 -1
- package/dist/lib/server/decisioning/account.js.map +1 -1
- package/dist/lib/server/decisioning/account.mjs.map +1 -1
- package/dist/lib/server/decisioning/pagination.d.mts +2 -0
- package/dist/lib/server/decisioning/pagination.d.ts +2 -0
- package/dist/lib/server/decisioning/pagination.d.ts.map +1 -1
- package/dist/lib/server/decisioning/pagination.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.d.mts +20 -14
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts +20 -14
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +32 -6
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.mjs +32 -6
- package/dist/lib/server/decisioning/runtime/from-platform.mjs.map +1 -1
- package/dist/lib/server/decisioning/specialisms/sales.d.mts +16 -4
- package/dist/lib/server/decisioning/specialisms/sales.d.ts +16 -4
- package/dist/lib/server/decisioning/specialisms/sales.d.ts.map +1 -1
- package/dist/lib/server/decisioning/specialisms/sales.js.map +1 -1
- package/dist/lib/server/index.d.ts.map +1 -1
- package/dist/lib/server/index.js.map +1 -1
- package/dist/lib/server/index.mjs.map +1 -1
- package/dist/lib/server/legacy/v5/index.d.mts +2 -1
- package/dist/lib/server/legacy/v5/index.d.ts +2 -1
- package/dist/lib/server/legacy/v5/index.d.ts.map +1 -1
- package/dist/lib/server/legacy/v5/index.js.map +1 -1
- package/dist/lib/server/legacy/v5/index.mjs.map +1 -1
- package/dist/lib/testing/compliance/comply.d.mts +2 -0
- package/dist/lib/testing/compliance/comply.d.ts +2 -0
- package/dist/lib/testing/compliance/comply.d.ts.map +1 -1
- package/dist/lib/testing/compliance/comply.js +45 -14
- package/dist/lib/testing/compliance/comply.js.map +1 -1
- package/dist/lib/testing/compliance/comply.mjs +45 -14
- package/dist/lib/testing/compliance/comply.mjs.map +1 -1
- package/dist/lib/testing/compliance/index.d.mts +1 -1
- package/dist/lib/testing/compliance/index.d.ts +1 -1
- package/dist/lib/testing/compliance/index.d.ts.map +1 -1
- package/dist/lib/testing/compliance/index.js.map +1 -1
- package/dist/lib/testing/compliance/index.mjs.map +1 -1
- package/dist/lib/testing/compliance/storyboard-tracks.d.ts.map +1 -1
- package/dist/lib/testing/compliance/storyboard-tracks.js +9 -1
- package/dist/lib/testing/compliance/storyboard-tracks.js.map +1 -1
- package/dist/lib/testing/compliance/storyboard-tracks.mjs +9 -1
- package/dist/lib/testing/compliance/storyboard-tracks.mjs.map +1 -1
- package/dist/lib/testing/compliance/types.d.mts +26 -25
- package/dist/lib/testing/compliance/types.d.ts +26 -25
- package/dist/lib/testing/compliance/types.d.ts.map +1 -1
- package/dist/lib/testing/compliance/types.js.map +1 -1
- package/dist/lib/testing/index.d.mts +2 -2
- package/dist/lib/testing/index.d.ts +2 -2
- package/dist/lib/testing/index.d.ts.map +1 -1
- package/dist/lib/testing/index.js +16 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/index.mjs +16 -0
- package/dist/lib/testing/index.mjs.map +1 -1
- package/dist/lib/testing/scenarios/media-buy.d.ts.map +1 -1
- package/dist/lib/testing/scenarios/media-buy.js +20 -12
- package/dist/lib/testing/scenarios/media-buy.js.map +1 -1
- package/dist/lib/testing/scenarios/media-buy.mjs +20 -12
- package/dist/lib/testing/scenarios/media-buy.mjs.map +1 -1
- package/dist/lib/testing/storyboard/creative-assets.d.mts +23 -0
- package/dist/lib/testing/storyboard/creative-assets.d.ts +23 -0
- package/dist/lib/testing/storyboard/creative-assets.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/creative-assets.js +322 -45
- package/dist/lib/testing/storyboard/creative-assets.js.map +1 -1
- package/dist/lib/testing/storyboard/creative-assets.mjs +321 -45
- package/dist/lib/testing/storyboard/creative-assets.mjs.map +1 -1
- package/dist/lib/testing/storyboard/index.d.mts +4 -1
- package/dist/lib/testing/storyboard/index.d.ts +4 -1
- package/dist/lib/testing/storyboard/index.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/index.js +20 -0
- package/dist/lib/testing/storyboard/index.js.map +1 -1
- package/dist/lib/testing/storyboard/index.mjs +19 -0
- package/dist/lib/testing/storyboard/index.mjs.map +1 -1
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.mts +21 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.ts +22 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.js +668 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.js.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.mjs +638 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.mjs.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.mts +5 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.ts +6 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.js +44 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.js.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.mjs +20 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/index.mjs.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.mts +38 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.ts +39 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.js +17 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.js.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.mjs +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/types.mjs.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.mts +32 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.ts +33 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.js +105 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.js.map +1 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.mjs +80 -0
- package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.mjs.map +1 -0
- package/dist/lib/testing/storyboard/probes.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/probes.js +2 -1
- package/dist/lib/testing/storyboard/probes.js.map +1 -1
- package/dist/lib/testing/storyboard/probes.mjs +2 -1
- package/dist/lib/testing/storyboard/probes.mjs.map +1 -1
- package/dist/lib/testing/storyboard/runner.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/runner.js +382 -69
- package/dist/lib/testing/storyboard/runner.js.map +1 -1
- package/dist/lib/testing/storyboard/runner.mjs +387 -70
- package/dist/lib/testing/storyboard/runner.mjs.map +1 -1
- package/dist/lib/testing/storyboard/seeding.js +3 -1
- package/dist/lib/testing/storyboard/seeding.js.map +1 -1
- package/dist/lib/testing/storyboard/seeding.mjs +3 -1
- package/dist/lib/testing/storyboard/seeding.mjs.map +1 -1
- package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/task-map.js +16 -2
- package/dist/lib/testing/storyboard/task-map.js.map +1 -1
- package/dist/lib/testing/storyboard/task-map.mjs +16 -2
- package/dist/lib/testing/storyboard/task-map.mjs.map +1 -1
- package/dist/lib/testing/storyboard/trusted-match-context-replay.d.mts +11 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.d.ts +12 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.js +340 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.js.map +1 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.mjs +316 -0
- package/dist/lib/testing/storyboard/trusted-match-context-replay.mjs.map +1 -0
- package/dist/lib/testing/storyboard/types.d.mts +84 -15
- package/dist/lib/testing/storyboard/types.d.ts +84 -15
- package/dist/lib/testing/storyboard/types.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/types.js +2 -0
- package/dist/lib/testing/storyboard/types.js.map +1 -1
- package/dist/lib/testing/storyboard/types.mjs +2 -0
- package/dist/lib/testing/storyboard/types.mjs.map +1 -1
- package/dist/lib/testing/storyboard/validations.d.mts +3 -0
- package/dist/lib/testing/storyboard/validations.d.ts +3 -0
- package/dist/lib/testing/storyboard/validations.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/validations.js +64 -24
- package/dist/lib/testing/storyboard/validations.js.map +1 -1
- package/dist/lib/testing/storyboard/validations.mjs +64 -24
- package/dist/lib/testing/storyboard/validations.mjs.map +1 -1
- package/dist/lib/types/server-payload-aliases.d.mts +8 -2
- package/dist/lib/types/server-payload-aliases.d.ts +8 -2
- package/dist/lib/types/server-payload-aliases.d.ts.map +1 -1
- package/dist/lib/types/server-payload-aliases.js.map +1 -1
- package/dist/lib/upstream-recorder/recorder.d.mts +7 -18
- package/dist/lib/upstream-recorder/recorder.d.ts +7 -18
- package/dist/lib/upstream-recorder/recorder.d.ts.map +1 -1
- package/dist/lib/upstream-recorder/recorder.js +17 -10
- package/dist/lib/upstream-recorder/recorder.js.map +1 -1
- package/dist/lib/upstream-recorder/recorder.mjs +12 -5
- package/dist/lib/upstream-recorder/recorder.mjs.map +1 -1
- package/dist/lib/utils/redact-secrets.d.ts.map +1 -1
- package/dist/lib/utils/redact-secrets.js +7 -1
- package/dist/lib/utils/redact-secrets.js.map +1 -1
- package/dist/lib/utils/redact-secrets.mjs +7 -1
- package/dist/lib/utils/redact-secrets.mjs.map +1 -1
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/version.mjs +3 -3
- package/dist/lib/version.mjs.map +1 -1
- package/docs/guides/BUILD-AN-AGENT.md +4 -2
- package/docs/llms.txt +1 -1
- package/package.json +2 -2
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
* @see docs/guides/account-resolution.md
|
|
41
41
|
* @public
|
|
42
42
|
*/
|
|
43
|
-
import type {
|
|
44
|
-
import type {
|
|
43
|
+
import type { ListAccountsRequest } from '../types/tools.generated.mjs';
|
|
44
|
+
import type { Account, AccountStore, ResolveContext } from '../server/decisioning/account.mjs';
|
|
45
|
+
import type { CursorPage } from '../server/decisioning/pagination.mjs';
|
|
45
46
|
/**
|
|
46
47
|
* Options for {@link createRosterAccountStore}.
|
|
47
48
|
*
|
|
@@ -89,16 +90,16 @@ export interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<strin
|
|
|
89
90
|
* **Push filter + pagination down.** The naive shape — fetch the full
|
|
90
91
|
* roster, filter+slice in memory — works for tens of accounts per tenant
|
|
91
92
|
* but does not scale. Adopters with a Postgres-backed roster should map
|
|
92
|
-
* `
|
|
93
|
-
* `cursor`
|
|
94
|
-
* the adopter is the source of truth.
|
|
93
|
+
* `request.account` / `request.status` / `request.sandbox` and
|
|
94
|
+
* `request.pagination.{cursor,max_results}` to a SQL query. The helper
|
|
95
|
+
* does NOT post-filter; the adopter is the source of truth.
|
|
95
96
|
*
|
|
96
|
-
* **No default status filter.** `
|
|
97
|
+
* **No default status filter.** `request.status` is `undefined` unless the
|
|
97
98
|
* buyer passed it. If you want production callers to see only `active` +
|
|
98
99
|
* `pending_approval` by default, apply that fallback inside your query —
|
|
99
|
-
* the helper passes `
|
|
100
|
+
* the helper passes `request` through verbatim.
|
|
100
101
|
*/
|
|
101
|
-
list?: (
|
|
102
|
+
list?: (request: ListAccountsRequest, ctx: ResolveContext | undefined) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;
|
|
102
103
|
/**
|
|
103
104
|
* Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by
|
|
104
105
|
* `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
* @see docs/guides/account-resolution.md
|
|
41
41
|
* @public
|
|
42
42
|
*/
|
|
43
|
-
import type {
|
|
44
|
-
import type {
|
|
43
|
+
import type { ListAccountsRequest } from '../types/tools.generated';
|
|
44
|
+
import type { Account, AccountStore, ResolveContext } from '../server/decisioning/account';
|
|
45
|
+
import type { CursorPage } from '../server/decisioning/pagination';
|
|
45
46
|
/**
|
|
46
47
|
* Options for {@link createRosterAccountStore}.
|
|
47
48
|
*
|
|
@@ -89,16 +90,16 @@ export interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<strin
|
|
|
89
90
|
* **Push filter + pagination down.** The naive shape — fetch the full
|
|
90
91
|
* roster, filter+slice in memory — works for tens of accounts per tenant
|
|
91
92
|
* but does not scale. Adopters with a Postgres-backed roster should map
|
|
92
|
-
* `
|
|
93
|
-
* `cursor`
|
|
94
|
-
* the adopter is the source of truth.
|
|
93
|
+
* `request.account` / `request.status` / `request.sandbox` and
|
|
94
|
+
* `request.pagination.{cursor,max_results}` to a SQL query. The helper
|
|
95
|
+
* does NOT post-filter; the adopter is the source of truth.
|
|
95
96
|
*
|
|
96
|
-
* **No default status filter.** `
|
|
97
|
+
* **No default status filter.** `request.status` is `undefined` unless the
|
|
97
98
|
* buyer passed it. If you want production callers to see only `active` +
|
|
98
99
|
* `pending_approval` by default, apply that fallback inside your query —
|
|
99
|
-
* the helper passes `
|
|
100
|
+
* the helper passes `request` through verbatim.
|
|
100
101
|
*/
|
|
101
|
-
list?: (
|
|
102
|
+
list?: (request: ListAccountsRequest, ctx: ResolveContext | undefined) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;
|
|
102
103
|
/**
|
|
103
104
|
* Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by
|
|
104
105
|
* `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"roster-account-store.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/roster-account-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;
|
|
1
|
+
{"version":3,"file":"roster-account-store.d.ts","sourceRoot":"","sources":["../../../src/lib/adapters/roster-account-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAoB,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACtF,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE3F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kCAAkC,CAAC;AAEnE;;;;GAIG;AACH,MAAM,WAAW,yBAAyB,CAAC,YAAY,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACzF;;;;;;;;;;;OAWG;IACH,MAAM,EAAE,CACN,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,cAAc,GAAG,SAAS,KAC5B,YAAY,GAAG,SAAS,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAC;IAElE;;;;;;;;;;;;;;;OAeG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,cAAc,GAAG,SAAS,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAEvF;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,CACL,OAAO,EAAE,mBAAmB,EAC5B,GAAG,EAAE,cAAc,GAAG,SAAS,KAC5B,UAAU,CAAC,YAAY,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC;IAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAyCG;IACH,iBAAiB,CAAC,EAAE,CAClB,GAAG,EAAE,SAAS,EACd,GAAG,EAAE,cAAc,GAAG,SAAS,KAC5B,YAAY,GAAG,SAAS,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsIG;AACH,wBAAgB,wBAAwB,CAAC,YAAY,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACvF,OAAO,EAAE,yBAAyB,CAAC,YAAY,EAAE,QAAQ,CAAC,GACzD,YAAY,CAAC,QAAQ,CAAC,CAuCxB"}
|
|
@@ -44,7 +44,8 @@ function createRosterAccountStore(options) {
|
|
|
44
44
|
const page = await adopterList(filter, ctx);
|
|
45
45
|
return {
|
|
46
46
|
items: page.items.map((entry) => options.toAccount(entry, ctx)),
|
|
47
|
-
...page.nextCursor !== void 0 && { nextCursor: page.nextCursor }
|
|
47
|
+
...page.nextCursor !== void 0 && { nextCursor: page.nextCursor },
|
|
48
|
+
...page.totalCount !== void 0 && { totalCount: page.totalCount }
|
|
48
49
|
};
|
|
49
50
|
};
|
|
50
51
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/adapters/roster-account-store.ts"],"sourcesContent":["/**\n * Roster-backed `AccountStore` factory for `resolution: 'explicit'` platforms\n * where the publisher curates accounts out-of-band (admin UI, config file,\n * publisher-managed DB row) and buyers pass `account_id` on every request.\n *\n * Pairs with the existing reference adapters. Pick by asking *who creates the\n * account?*:\n * - **Buyer self-onboards via `sync_accounts`** → {@link InMemoryImplicitAccountStore}\n * (Shape A, `resolution: 'implicit'`). Framework owns persistence; the\n * buyer's first request to a tenant-scoped tool resolves from a prior\n * sync. LinkedIn, some retail-media operators.\n * - **Upstream OAuth API owns the roster** → {@link createOAuthPassthroughResolver}\n * (Shape B, `resolution: 'explicit'`). Returns just the `resolve` function;\n * adopter composes into an AccountStore. Snap, Meta, TikTok — anywhere\n * the buyer's bearer is the lookup key for an upstream `/me/adaccounts`.\n * - **Publisher ops curates the roster out-of-band** → `createRosterAccountStore`\n * (this file, Shape C, `resolution: 'explicit'`). Adopter keeps the\n * persistence layer (storefront table, admin-UI-managed JSON column,\n * in-memory map); the SDK provides the AccountStore plumbing. Most\n * SSPs, broadcasters, and retail-media networks where AE/CSM provisions\n * the account in an internal admin tool before the buyer ever calls.\n * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore`\n * (Shape D, `resolution: 'derived'`). Single-tenant agents\n * (audiostack, flashtalking, single-namespace retail-media) where there\n * is no `account_id` on the wire and every call authenticates with the\n * per-request bearer.\n *\n * Design notes:\n * - Lookup is a point function, not a roster getter. An adopter with\n * thousands of accounts per tenant in Postgres can issue a SELECT WHERE\n * id = $1 instead of materializing the full array on every request.\n * - `list` is opt-in. Omit it and the framework emits UNSUPPORTED_FEATURE\n * for `list_accounts` calls (matches `AccountStore.list?` semantics).\n * Provide it and the adopter pushes filter+page down to whatever index\n * they have.\n * - No `upsert`, `reportUsage`, `getAccountFinancials`, or `refreshToken`.\n * Adopters who need those compose the helper output:\n * `{ ...createRosterAccountStore(...), refreshToken: async (a) => { ... } }`.\n *\n * @see docs/guides/account-resolution.md\n * @public\n */\n\nimport type { AccountReference } from '../types/tools.generated';\nimport type { Account, AccountFilter, AccountStore, ResolveContext } from '../server/decisioning/account';\nimport { refAccountId } from '../server/decisioning/account';\nimport type { CursorPage, CursorRequest } from '../server/decisioning/pagination';\n\n/**\n * Options for {@link createRosterAccountStore}.\n *\n * @public\n */\nexport interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<string, unknown>> {\n /**\n * Point-lookup against the adopter's roster source. Return the entry for\n * a given `account_id`, or `undefined` when no row matches.\n *\n * Called once per `resolve()` with an `account_id`-shaped reference. Wire\n * shape `{ brand, operator }` and missing refs do NOT call `lookup`; see\n * the resolve behavior below.\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and projects to `ACCOUNT_NOT_FOUND`.\n */\n lookup: (\n accountId: string,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n\n /**\n * Convert a roster entry to the framework's `Account<TCtxMeta>` shape.\n *\n * The adopter's roster row typically lacks `ctx_metadata` (which is a\n * framework-internal field). This mapper is where the adopter populates\n * it — upstream IDs, per-account caches, anything specialism methods will\n * read off `ctx.account.ctx_metadata` later.\n *\n * **DO NOT put credentials in `ctx_metadata`.** See\n * `docs/guides/CTX-METADATA-SAFETY.md` for the rationale and the\n * recommended re-derive-per-request pattern (read tokens off\n * `ctx.authInfo` instead).\n *\n * Adopters MAY omit `authInfo` from the returned `Account` — the\n * framework auto-attaches the principal from `ctx.authInfo` when absent.\n */\n toAccount: (entry: TRosterEntry, ctx: ResolveContext | undefined) => Account<TCtxMeta>;\n\n /**\n * Optional `list_accounts` implementation. The adopter receives the wire\n * filter + cursor request and returns a page of roster entries; the\n * helper threads each entry through `toAccount` before returning.\n *\n * Omit to leave `list_accounts` unimplemented (framework returns\n * `UNSUPPORTED_FEATURE`).\n *\n * **Push filter + pagination down.** The naive shape — fetch the full\n * roster, filter+slice in memory — works for tens of accounts per tenant\n * but does not scale. Adopters with a Postgres-backed roster should map\n * `filter.brand_domain` / `filter.operator` / `filter.status[]` /\n * `cursor` / `limit` to a SQL query. The helper does NOT post-filter;\n * the adopter is the source of truth.\n *\n * **No default status filter.** `filter.status` is `undefined` unless the\n * buyer passed it. If you want production callers to see only `active` +\n * `pending_approval` by default, apply that fallback inside your query —\n * the helper passes `filter` through verbatim.\n */\n list?: (\n filter: AccountFilter & CursorRequest,\n ctx: ResolveContext | undefined\n ) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;\n\n /**\n * Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by\n * `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,\n * and any discovery-phase tool that does not require a buyer-selected account.\n *\n * Return a synthetic publisher-wide roster entry; `toAccount` is applied to\n * it before the result is returned to the framework. Return `undefined` to\n * fall back to `null` (same as omitting this option — `ctx.account` is\n * `undefined` in the handler).\n *\n * The `ref` parameter is always `undefined` here — it is present for\n * signature parity with the framework's `resolve(ref, ctx)` shape so\n * code-generation tools produce consistent two-arg handler signatures.\n * Ignore it (use `(_ref, ctx) => ...`).\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and returns `null` to the framework.\n *\n * **Brand+operator refs are not routed here.** A `{ brand, operator }` ref\n * without an `account_id` still falls through to `null`. Adopters who need\n * to handle brand-arm refs must override `resolve` on the returned store via\n * a spread.\n *\n * **`toAccount` is called on your return value** with the same `ctx`,\n * identical to the `lookup` path. Ensure your synthesized entry satisfies\n * all required fields of `TRosterEntry`.\n *\n * **Auth-derived lookup.** If your singleton should be derived from\n * `ctx.authInfo` (e.g. a per-tenant publisher account keyed on the OAuth\n * client), and that derivation needs to re-invoke `lookup`, use the\n * spread-override pattern on the returned store instead — a `resolveWithoutRef`\n * that calls back into `lookup` is more naturally expressed as a `resolve`\n * override so the call graph stays flat.\n *\n * **If you also override `resolve` on the spread**, `resolveWithoutRef` is\n * not called — the spread override takes precedence.\n *\n * Omit when `ctx.account === undefined` is acceptable for ref-less tools\n * (handlers can narrow on it and fall back to platform-level config from\n * their closure).\n */\n resolveWithoutRef?: (\n ref: undefined,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` from an adopter-supplied roster source.\n *\n * The adopter brings persistence (DB row, admin-UI-managed JSON column,\n * in-memory Map, file). The helper provides:\n * - `resolution: 'explicit'` declaration\n * - `account_id`-arm dispatch from the wire reference\n * - Mapping from roster entry → `Account<TCtxMeta>` via `toAccount`\n * - Optional `list_accounts` plumbing with cursor envelope passthrough\n * - `null` return for `{ brand, operator }`-shaped refs and ref-less\n * calls (publisher-curated platforms expect explicit ids)\n *\n * Adopters who need `upsert` (buyer-driven write paths via `sync_accounts`),\n * `refreshToken`, `reportUsage`, or `getAccountFinancials` compose on top\n * of the returned store with a spread:\n *\n * ```ts\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * refreshToken: async (account) => myUpstream.refresh(account),\n * };\n * ```\n *\n * **Ref-less calls (singleton fallback).** `list_creative_formats`,\n * `preview_creative`, and `provide_performance_feedback` call\n * `accounts.resolve(undefined, ctx)`. Use `resolveWithoutRef` when your\n * platform needs a synthetic publisher-wide account for these tools:\n *\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: () => ({ id: '__publisher__', label: 'Publisher', tenantId: myPlatformId }),\n * });\n * ```\n *\n * The returned entry flows through `toAccount` just like a `lookup` hit.\n * When omitted the helper returns `null` for ref-less calls — handlers can\n * narrow on `ctx.account === undefined` and fall back to platform-level\n * config from their closure.\n *\n * **Ref-less calls (singleton from options):**\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: (_ref, ctx) => ({\n * id: '__publisher__',\n * name: 'Publisher',\n * tenant_id: deriveTenant(ctx?.authInfo),\n * }),\n * });\n * ```\n *\n * **Ref-less calls (auth-derived lookup).** When the singleton should be\n * derived from the caller's principal and needs to call back into `lookup`,\n * use the spread-override pattern instead:\n *\n * ```ts\n * const base = createRosterAccountStore({ lookup, toAccount });\n * const accounts: AccountStore<MyMeta> = {\n * ...base,\n * resolve: async (ref, ctx) => {\n * if (ref === undefined) {\n * const id = deriveAccountIdFromAuth(ctx?.authInfo);\n * return id ? base.resolve({ account_id: id }, ctx) : null;\n * }\n * return base.resolve(ref, ctx);\n * },\n * };\n * ```\n *\n * **Hybrid roster + buyer-updatable fields.** Some publisher-curated\n * platforms (GAM, FreeWheel, several retail-media networks) let the buyer\n * PATCH a narrow set of fields — billing contact, AP email, agency-of-record\n * cert — on a publisher-provisioned account, while keeping creation and\n * commercial terms (credit limit, rate card) read-only to the buyer. Compose\n * a partial `upsert` over the roster store and gate it on a field allowlist:\n *\n * ```ts\n * const BUYER_WRITABLE = new Set(['billing_entity', 'setup']);\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * upsert: async (refs, ctx) => refs.map(r => applyBuyerPatch(r, BUYER_WRITABLE, ctx)),\n * };\n * ```\n *\n * **Brand-arm refs return `null`.** Buyers who pass `{ brand, operator }`\n * (no `account_id`) hit the framework's `ACCOUNT_NOT_FOUND` envelope. The\n * helper cannot synthesize `INVALID_REQUEST` from inside `resolve` — if your\n * platform needs to reject brand-arm refs as a wire-shape error rather than\n * a not-found, wrap `resolve` and throw `AdcpError('INVALID_REQUEST', { field:\n * 'account.brand' })` before delegating to the helper.\n *\n * @example In-memory roster (tests, small fixed configs):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: (id) => roster.get(id),\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: 'active',\n * ctx_metadata: { upstreamId: row.upstream_id },\n * }),\n * });\n * ```\n *\n * @example Postgres-backed roster (publisher with admin UI):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: async (id, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * return await db.oneOrNone(\n * 'SELECT * FROM storefront_accounts WHERE id = $1 AND tenant = $2',\n * [id, tenantId],\n * );\n * },\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: row.status,\n * brand: row.brand,\n * operator: row.operator,\n * ctx_metadata: { tenant: row.tenant, upstreamRef: row.upstream_ref },\n * }),\n * list: async (filter, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * const rows = await db.any(buildListQuery(filter, tenantId));\n * return { items: rows, nextCursor: nextCursorFor(rows, filter) };\n * },\n * });\n * ```\n *\n * @public\n */\nexport function createRosterAccountStore<TRosterEntry, TCtxMeta = Record<string, unknown>>(\n options: RosterAccountStoreOptions<TRosterEntry, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolution: 'explicit',\n\n async resolve(ref: AccountReference | undefined, ctx?: ResolveContext): Promise<Account<TCtxMeta> | null> {\n const accountId = refAccountId(ref);\n if (accountId !== undefined) {\n const entry = await options.lookup(accountId, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // ref is undefined (no account field on wire) — delegate to resolveWithoutRef if provided.\n // Brand+operator refs (ref !== undefined but no account_id) fall through to null below;\n // resolveWithoutRef is intentionally not called for them.\n if (ref === undefined && options.resolveWithoutRef !== undefined) {\n const entry = await options.resolveWithoutRef(undefined, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // brand+operator-shaped refs (no account_id) and unhandled ref-less calls → null.\n // Adopters who need brand-arm resolution or auth-derived ref-less lookup wrap\n // `resolve` via spread — see the JSDoc on `createRosterAccountStore`.\n return null;\n },\n };\n\n if (options.list !== undefined) {\n const adopterList = options.list;\n store.list = async (filter, ctx) => {\n const page = await adopterList(filter, ctx);\n return {\n items: page.items.map(entry => options.toAccount(entry, ctx)),\n ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),\n };\n };\n }\n\n return store;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA6CA,qBAA6B;AA4PtB,SAAS,yBACd,SACwB;AACxB,QAAM,QAAgC;AAAA,IACpC,YAAY;AAAA,IAEZ,MAAM,QAAQ,KAAmC,KAAyD;AACxG,YAAM,gBAAY,6BAAa,GAAG;AAClC,UAAI,cAAc,QAAW;AAC3B,cAAM,QAAQ,MAAM,QAAQ,OAAO,WAAW,GAAG;AACjD,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,UAAI,QAAQ,UAAa,QAAQ,sBAAsB,QAAW;AAChE,cAAM,QAAQ,MAAM,QAAQ,kBAAkB,QAAW,GAAG;AAC5D,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,QAAQ,SAAS,QAAW;AAC9B,UAAM,cAAc,QAAQ;AAC5B,UAAM,OAAO,OAAO,QAAQ,QAAQ;AAClC,YAAM,OAAO,MAAM,YAAY,QAAQ,GAAG;AAC1C,aAAO;AAAA,QACL,OAAO,KAAK,MAAM,IAAI,WAAS,QAAQ,UAAU,OAAO,GAAG,CAAC;AAAA,QAC5D,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/adapters/roster-account-store.ts"],"sourcesContent":["/**\n * Roster-backed `AccountStore` factory for `resolution: 'explicit'` platforms\n * where the publisher curates accounts out-of-band (admin UI, config file,\n * publisher-managed DB row) and buyers pass `account_id` on every request.\n *\n * Pairs with the existing reference adapters. Pick by asking *who creates the\n * account?*:\n * - **Buyer self-onboards via `sync_accounts`** → {@link InMemoryImplicitAccountStore}\n * (Shape A, `resolution: 'implicit'`). Framework owns persistence; the\n * buyer's first request to a tenant-scoped tool resolves from a prior\n * sync. LinkedIn, some retail-media operators.\n * - **Upstream OAuth API owns the roster** → {@link createOAuthPassthroughResolver}\n * (Shape B, `resolution: 'explicit'`). Returns just the `resolve` function;\n * adopter composes into an AccountStore. Snap, Meta, TikTok — anywhere\n * the buyer's bearer is the lookup key for an upstream `/me/adaccounts`.\n * - **Publisher ops curates the roster out-of-band** → `createRosterAccountStore`\n * (this file, Shape C, `resolution: 'explicit'`). Adopter keeps the\n * persistence layer (storefront table, admin-UI-managed JSON column,\n * in-memory map); the SDK provides the AccountStore plumbing. Most\n * SSPs, broadcasters, and retail-media networks where AE/CSM provisions\n * the account in an internal admin tool before the buyer ever calls.\n * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore`\n * (Shape D, `resolution: 'derived'`). Single-tenant agents\n * (audiostack, flashtalking, single-namespace retail-media) where there\n * is no `account_id` on the wire and every call authenticates with the\n * per-request bearer.\n *\n * Design notes:\n * - Lookup is a point function, not a roster getter. An adopter with\n * thousands of accounts per tenant in Postgres can issue a SELECT WHERE\n * id = $1 instead of materializing the full array on every request.\n * - `list` is opt-in. Omit it and the framework emits UNSUPPORTED_FEATURE\n * for `list_accounts` calls (matches `AccountStore.list?` semantics).\n * Provide it and the adopter pushes filter+page down to whatever index\n * they have.\n * - No `upsert`, `reportUsage`, `getAccountFinancials`, or `refreshToken`.\n * Adopters who need those compose the helper output:\n * `{ ...createRosterAccountStore(...), refreshToken: async (a) => { ... } }`.\n *\n * @see docs/guides/account-resolution.md\n * @public\n */\n\nimport type { AccountReference, ListAccountsRequest } from '../types/tools.generated';\nimport type { Account, AccountStore, ResolveContext } from '../server/decisioning/account';\nimport { refAccountId } from '../server/decisioning/account';\nimport type { CursorPage } from '../server/decisioning/pagination';\n\n/**\n * Options for {@link createRosterAccountStore}.\n *\n * @public\n */\nexport interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<string, unknown>> {\n /**\n * Point-lookup against the adopter's roster source. Return the entry for\n * a given `account_id`, or `undefined` when no row matches.\n *\n * Called once per `resolve()` with an `account_id`-shaped reference. Wire\n * shape `{ brand, operator }` and missing refs do NOT call `lookup`; see\n * the resolve behavior below.\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and projects to `ACCOUNT_NOT_FOUND`.\n */\n lookup: (\n accountId: string,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n\n /**\n * Convert a roster entry to the framework's `Account<TCtxMeta>` shape.\n *\n * The adopter's roster row typically lacks `ctx_metadata` (which is a\n * framework-internal field). This mapper is where the adopter populates\n * it — upstream IDs, per-account caches, anything specialism methods will\n * read off `ctx.account.ctx_metadata` later.\n *\n * **DO NOT put credentials in `ctx_metadata`.** See\n * `docs/guides/CTX-METADATA-SAFETY.md` for the rationale and the\n * recommended re-derive-per-request pattern (read tokens off\n * `ctx.authInfo` instead).\n *\n * Adopters MAY omit `authInfo` from the returned `Account` — the\n * framework auto-attaches the principal from `ctx.authInfo` when absent.\n */\n toAccount: (entry: TRosterEntry, ctx: ResolveContext | undefined) => Account<TCtxMeta>;\n\n /**\n * Optional `list_accounts` implementation. The adopter receives the wire\n * filter + cursor request and returns a page of roster entries; the\n * helper threads each entry through `toAccount` before returning.\n *\n * Omit to leave `list_accounts` unimplemented (framework returns\n * `UNSUPPORTED_FEATURE`).\n *\n * **Push filter + pagination down.** The naive shape — fetch the full\n * roster, filter+slice in memory — works for tens of accounts per tenant\n * but does not scale. Adopters with a Postgres-backed roster should map\n * `request.account` / `request.status` / `request.sandbox` and\n * `request.pagination.{cursor,max_results}` to a SQL query. The helper\n * does NOT post-filter; the adopter is the source of truth.\n *\n * **No default status filter.** `request.status` is `undefined` unless the\n * buyer passed it. If you want production callers to see only `active` +\n * `pending_approval` by default, apply that fallback inside your query —\n * the helper passes `request` through verbatim.\n */\n list?: (\n request: ListAccountsRequest,\n ctx: ResolveContext | undefined\n ) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;\n\n /**\n * Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by\n * `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,\n * and any discovery-phase tool that does not require a buyer-selected account.\n *\n * Return a synthetic publisher-wide roster entry; `toAccount` is applied to\n * it before the result is returned to the framework. Return `undefined` to\n * fall back to `null` (same as omitting this option — `ctx.account` is\n * `undefined` in the handler).\n *\n * The `ref` parameter is always `undefined` here — it is present for\n * signature parity with the framework's `resolve(ref, ctx)` shape so\n * code-generation tools produce consistent two-arg handler signatures.\n * Ignore it (use `(_ref, ctx) => ...`).\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and returns `null` to the framework.\n *\n * **Brand+operator refs are not routed here.** A `{ brand, operator }` ref\n * without an `account_id` still falls through to `null`. Adopters who need\n * to handle brand-arm refs must override `resolve` on the returned store via\n * a spread.\n *\n * **`toAccount` is called on your return value** with the same `ctx`,\n * identical to the `lookup` path. Ensure your synthesized entry satisfies\n * all required fields of `TRosterEntry`.\n *\n * **Auth-derived lookup.** If your singleton should be derived from\n * `ctx.authInfo` (e.g. a per-tenant publisher account keyed on the OAuth\n * client), and that derivation needs to re-invoke `lookup`, use the\n * spread-override pattern on the returned store instead — a `resolveWithoutRef`\n * that calls back into `lookup` is more naturally expressed as a `resolve`\n * override so the call graph stays flat.\n *\n * **If you also override `resolve` on the spread**, `resolveWithoutRef` is\n * not called — the spread override takes precedence.\n *\n * Omit when `ctx.account === undefined` is acceptable for ref-less tools\n * (handlers can narrow on it and fall back to platform-level config from\n * their closure).\n */\n resolveWithoutRef?: (\n ref: undefined,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` from an adopter-supplied roster source.\n *\n * The adopter brings persistence (DB row, admin-UI-managed JSON column,\n * in-memory Map, file). The helper provides:\n * - `resolution: 'explicit'` declaration\n * - `account_id`-arm dispatch from the wire reference\n * - Mapping from roster entry → `Account<TCtxMeta>` via `toAccount`\n * - Optional `list_accounts` plumbing with cursor envelope passthrough\n * - `null` return for `{ brand, operator }`-shaped refs and ref-less\n * calls (publisher-curated platforms expect explicit ids)\n *\n * Adopters who need `upsert` (buyer-driven write paths via `sync_accounts`),\n * `refreshToken`, `reportUsage`, or `getAccountFinancials` compose on top\n * of the returned store with a spread:\n *\n * ```ts\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * refreshToken: async (account) => myUpstream.refresh(account),\n * };\n * ```\n *\n * **Ref-less calls (singleton fallback).** `list_creative_formats`,\n * `preview_creative`, and `provide_performance_feedback` call\n * `accounts.resolve(undefined, ctx)`. Use `resolveWithoutRef` when your\n * platform needs a synthetic publisher-wide account for these tools:\n *\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: () => ({ id: '__publisher__', label: 'Publisher', tenantId: myPlatformId }),\n * });\n * ```\n *\n * The returned entry flows through `toAccount` just like a `lookup` hit.\n * When omitted the helper returns `null` for ref-less calls — handlers can\n * narrow on `ctx.account === undefined` and fall back to platform-level\n * config from their closure.\n *\n * **Ref-less calls (singleton from options):**\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: (_ref, ctx) => ({\n * id: '__publisher__',\n * name: 'Publisher',\n * tenant_id: deriveTenant(ctx?.authInfo),\n * }),\n * });\n * ```\n *\n * **Ref-less calls (auth-derived lookup).** When the singleton should be\n * derived from the caller's principal and needs to call back into `lookup`,\n * use the spread-override pattern instead:\n *\n * ```ts\n * const base = createRosterAccountStore({ lookup, toAccount });\n * const accounts: AccountStore<MyMeta> = {\n * ...base,\n * resolve: async (ref, ctx) => {\n * if (ref === undefined) {\n * const id = deriveAccountIdFromAuth(ctx?.authInfo);\n * return id ? base.resolve({ account_id: id }, ctx) : null;\n * }\n * return base.resolve(ref, ctx);\n * },\n * };\n * ```\n *\n * **Hybrid roster + buyer-updatable fields.** Some publisher-curated\n * platforms (GAM, FreeWheel, several retail-media networks) let the buyer\n * PATCH a narrow set of fields — billing contact, AP email, agency-of-record\n * cert — on a publisher-provisioned account, while keeping creation and\n * commercial terms (credit limit, rate card) read-only to the buyer. Compose\n * a partial `upsert` over the roster store and gate it on a field allowlist:\n *\n * ```ts\n * const BUYER_WRITABLE = new Set(['billing_entity', 'setup']);\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * upsert: async (refs, ctx) => refs.map(r => applyBuyerPatch(r, BUYER_WRITABLE, ctx)),\n * };\n * ```\n *\n * **Brand-arm refs return `null`.** Buyers who pass `{ brand, operator }`\n * (no `account_id`) hit the framework's `ACCOUNT_NOT_FOUND` envelope. The\n * helper cannot synthesize `INVALID_REQUEST` from inside `resolve` — if your\n * platform needs to reject brand-arm refs as a wire-shape error rather than\n * a not-found, wrap `resolve` and throw `AdcpError('INVALID_REQUEST', { field:\n * 'account.brand' })` before delegating to the helper.\n *\n * @example In-memory roster (tests, small fixed configs):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: (id) => roster.get(id),\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: 'active',\n * ctx_metadata: { upstreamId: row.upstream_id },\n * }),\n * });\n * ```\n *\n * @example Postgres-backed roster (publisher with admin UI):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: async (id, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * return await db.oneOrNone(\n * 'SELECT * FROM storefront_accounts WHERE id = $1 AND tenant = $2',\n * [id, tenantId],\n * );\n * },\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: row.status,\n * brand: row.brand,\n * operator: row.operator,\n * ctx_metadata: { tenant: row.tenant, upstreamRef: row.upstream_ref },\n * }),\n * list: async (filter, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * const rows = await db.any(buildListQuery(filter, tenantId));\n * return { items: rows, nextCursor: nextCursorFor(rows, filter) };\n * },\n * });\n * ```\n *\n * @public\n */\nexport function createRosterAccountStore<TRosterEntry, TCtxMeta = Record<string, unknown>>(\n options: RosterAccountStoreOptions<TRosterEntry, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolution: 'explicit',\n\n async resolve(ref: AccountReference | undefined, ctx?: ResolveContext): Promise<Account<TCtxMeta> | null> {\n const accountId = refAccountId(ref);\n if (accountId !== undefined) {\n const entry = await options.lookup(accountId, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // ref is undefined (no account field on wire) — delegate to resolveWithoutRef if provided.\n // Brand+operator refs (ref !== undefined but no account_id) fall through to null below;\n // resolveWithoutRef is intentionally not called for them.\n if (ref === undefined && options.resolveWithoutRef !== undefined) {\n const entry = await options.resolveWithoutRef(undefined, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // brand+operator-shaped refs (no account_id) and unhandled ref-less calls → null.\n // Adopters who need brand-arm resolution or auth-derived ref-less lookup wrap\n // `resolve` via spread — see the JSDoc on `createRosterAccountStore`.\n return null;\n },\n };\n\n if (options.list !== undefined) {\n const adopterList = options.list;\n store.list = async (filter, ctx) => {\n const page = await adopterList(filter, ctx);\n return {\n items: page.items.map(entry => options.toAccount(entry, ctx)),\n ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),\n ...(page.totalCount !== undefined && { totalCount: page.totalCount }),\n };\n };\n }\n\n return store;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA6CA,qBAA6B;AA4PtB,SAAS,yBACd,SACwB;AACxB,QAAM,QAAgC;AAAA,IACpC,YAAY;AAAA,IAEZ,MAAM,QAAQ,KAAmC,KAAyD;AACxG,YAAM,gBAAY,6BAAa,GAAG;AAClC,UAAI,cAAc,QAAW;AAC3B,cAAM,QAAQ,MAAM,QAAQ,OAAO,WAAW,GAAG;AACjD,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,UAAI,QAAQ,UAAa,QAAQ,sBAAsB,QAAW;AAChE,cAAM,QAAQ,MAAM,QAAQ,kBAAkB,QAAW,GAAG;AAC5D,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,QAAQ,SAAS,QAAW;AAC9B,UAAM,cAAc,QAAQ;AAC5B,UAAM,OAAO,OAAO,QAAQ,QAAQ;AAClC,YAAM,OAAO,MAAM,YAAY,QAAQ,GAAG;AAC1C,aAAO;AAAA,QACL,OAAO,KAAK,MAAM,IAAI,WAAS,QAAQ,UAAU,OAAO,GAAG,CAAC;AAAA,QAC5D,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,QACnE,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -21,7 +21,8 @@ function createRosterAccountStore(options) {
|
|
|
21
21
|
const page = await adopterList(filter, ctx);
|
|
22
22
|
return {
|
|
23
23
|
items: page.items.map((entry) => options.toAccount(entry, ctx)),
|
|
24
|
-
...page.nextCursor !== void 0 && { nextCursor: page.nextCursor }
|
|
24
|
+
...page.nextCursor !== void 0 && { nextCursor: page.nextCursor },
|
|
25
|
+
...page.totalCount !== void 0 && { totalCount: page.totalCount }
|
|
25
26
|
};
|
|
26
27
|
};
|
|
27
28
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/adapters/roster-account-store.ts"],"sourcesContent":["/**\n * Roster-backed `AccountStore` factory for `resolution: 'explicit'` platforms\n * where the publisher curates accounts out-of-band (admin UI, config file,\n * publisher-managed DB row) and buyers pass `account_id` on every request.\n *\n * Pairs with the existing reference adapters. Pick by asking *who creates the\n * account?*:\n * - **Buyer self-onboards via `sync_accounts`** → {@link InMemoryImplicitAccountStore}\n * (Shape A, `resolution: 'implicit'`). Framework owns persistence; the\n * buyer's first request to a tenant-scoped tool resolves from a prior\n * sync. LinkedIn, some retail-media operators.\n * - **Upstream OAuth API owns the roster** → {@link createOAuthPassthroughResolver}\n * (Shape B, `resolution: 'explicit'`). Returns just the `resolve` function;\n * adopter composes into an AccountStore. Snap, Meta, TikTok — anywhere\n * the buyer's bearer is the lookup key for an upstream `/me/adaccounts`.\n * - **Publisher ops curates the roster out-of-band** → `createRosterAccountStore`\n * (this file, Shape C, `resolution: 'explicit'`). Adopter keeps the\n * persistence layer (storefront table, admin-UI-managed JSON column,\n * in-memory map); the SDK provides the AccountStore plumbing. Most\n * SSPs, broadcasters, and retail-media networks where AE/CSM provisions\n * the account in an internal admin tool before the buyer ever calls.\n * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore`\n * (Shape D, `resolution: 'derived'`). Single-tenant agents\n * (audiostack, flashtalking, single-namespace retail-media) where there\n * is no `account_id` on the wire and every call authenticates with the\n * per-request bearer.\n *\n * Design notes:\n * - Lookup is a point function, not a roster getter. An adopter with\n * thousands of accounts per tenant in Postgres can issue a SELECT WHERE\n * id = $1 instead of materializing the full array on every request.\n * - `list` is opt-in. Omit it and the framework emits UNSUPPORTED_FEATURE\n * for `list_accounts` calls (matches `AccountStore.list?` semantics).\n * Provide it and the adopter pushes filter+page down to whatever index\n * they have.\n * - No `upsert`, `reportUsage`, `getAccountFinancials`, or `refreshToken`.\n * Adopters who need those compose the helper output:\n * `{ ...createRosterAccountStore(...), refreshToken: async (a) => { ... } }`.\n *\n * @see docs/guides/account-resolution.md\n * @public\n */\n\nimport type { AccountReference } from '../types/tools.generated';\nimport type { Account, AccountFilter, AccountStore, ResolveContext } from '../server/decisioning/account';\nimport { refAccountId } from '../server/decisioning/account';\nimport type { CursorPage, CursorRequest } from '../server/decisioning/pagination';\n\n/**\n * Options for {@link createRosterAccountStore}.\n *\n * @public\n */\nexport interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<string, unknown>> {\n /**\n * Point-lookup against the adopter's roster source. Return the entry for\n * a given `account_id`, or `undefined` when no row matches.\n *\n * Called once per `resolve()` with an `account_id`-shaped reference. Wire\n * shape `{ brand, operator }` and missing refs do NOT call `lookup`; see\n * the resolve behavior below.\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and projects to `ACCOUNT_NOT_FOUND`.\n */\n lookup: (\n accountId: string,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n\n /**\n * Convert a roster entry to the framework's `Account<TCtxMeta>` shape.\n *\n * The adopter's roster row typically lacks `ctx_metadata` (which is a\n * framework-internal field). This mapper is where the adopter populates\n * it — upstream IDs, per-account caches, anything specialism methods will\n * read off `ctx.account.ctx_metadata` later.\n *\n * **DO NOT put credentials in `ctx_metadata`.** See\n * `docs/guides/CTX-METADATA-SAFETY.md` for the rationale and the\n * recommended re-derive-per-request pattern (read tokens off\n * `ctx.authInfo` instead).\n *\n * Adopters MAY omit `authInfo` from the returned `Account` — the\n * framework auto-attaches the principal from `ctx.authInfo` when absent.\n */\n toAccount: (entry: TRosterEntry, ctx: ResolveContext | undefined) => Account<TCtxMeta>;\n\n /**\n * Optional `list_accounts` implementation. The adopter receives the wire\n * filter + cursor request and returns a page of roster entries; the\n * helper threads each entry through `toAccount` before returning.\n *\n * Omit to leave `list_accounts` unimplemented (framework returns\n * `UNSUPPORTED_FEATURE`).\n *\n * **Push filter + pagination down.** The naive shape — fetch the full\n * roster, filter+slice in memory — works for tens of accounts per tenant\n * but does not scale. Adopters with a Postgres-backed roster should map\n * `filter.brand_domain` / `filter.operator` / `filter.status[]` /\n * `cursor` / `limit` to a SQL query. The helper does NOT post-filter;\n * the adopter is the source of truth.\n *\n * **No default status filter.** `filter.status` is `undefined` unless the\n * buyer passed it. If you want production callers to see only `active` +\n * `pending_approval` by default, apply that fallback inside your query —\n * the helper passes `filter` through verbatim.\n */\n list?: (\n filter: AccountFilter & CursorRequest,\n ctx: ResolveContext | undefined\n ) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;\n\n /**\n * Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by\n * `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,\n * and any discovery-phase tool that does not require a buyer-selected account.\n *\n * Return a synthetic publisher-wide roster entry; `toAccount` is applied to\n * it before the result is returned to the framework. Return `undefined` to\n * fall back to `null` (same as omitting this option — `ctx.account` is\n * `undefined` in the handler).\n *\n * The `ref` parameter is always `undefined` here — it is present for\n * signature parity with the framework's `resolve(ref, ctx)` shape so\n * code-generation tools produce consistent two-arg handler signatures.\n * Ignore it (use `(_ref, ctx) => ...`).\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and returns `null` to the framework.\n *\n * **Brand+operator refs are not routed here.** A `{ brand, operator }` ref\n * without an `account_id` still falls through to `null`. Adopters who need\n * to handle brand-arm refs must override `resolve` on the returned store via\n * a spread.\n *\n * **`toAccount` is called on your return value** with the same `ctx`,\n * identical to the `lookup` path. Ensure your synthesized entry satisfies\n * all required fields of `TRosterEntry`.\n *\n * **Auth-derived lookup.** If your singleton should be derived from\n * `ctx.authInfo` (e.g. a per-tenant publisher account keyed on the OAuth\n * client), and that derivation needs to re-invoke `lookup`, use the\n * spread-override pattern on the returned store instead — a `resolveWithoutRef`\n * that calls back into `lookup` is more naturally expressed as a `resolve`\n * override so the call graph stays flat.\n *\n * **If you also override `resolve` on the spread**, `resolveWithoutRef` is\n * not called — the spread override takes precedence.\n *\n * Omit when `ctx.account === undefined` is acceptable for ref-less tools\n * (handlers can narrow on it and fall back to platform-level config from\n * their closure).\n */\n resolveWithoutRef?: (\n ref: undefined,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` from an adopter-supplied roster source.\n *\n * The adopter brings persistence (DB row, admin-UI-managed JSON column,\n * in-memory Map, file). The helper provides:\n * - `resolution: 'explicit'` declaration\n * - `account_id`-arm dispatch from the wire reference\n * - Mapping from roster entry → `Account<TCtxMeta>` via `toAccount`\n * - Optional `list_accounts` plumbing with cursor envelope passthrough\n * - `null` return for `{ brand, operator }`-shaped refs and ref-less\n * calls (publisher-curated platforms expect explicit ids)\n *\n * Adopters who need `upsert` (buyer-driven write paths via `sync_accounts`),\n * `refreshToken`, `reportUsage`, or `getAccountFinancials` compose on top\n * of the returned store with a spread:\n *\n * ```ts\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * refreshToken: async (account) => myUpstream.refresh(account),\n * };\n * ```\n *\n * **Ref-less calls (singleton fallback).** `list_creative_formats`,\n * `preview_creative`, and `provide_performance_feedback` call\n * `accounts.resolve(undefined, ctx)`. Use `resolveWithoutRef` when your\n * platform needs a synthetic publisher-wide account for these tools:\n *\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: () => ({ id: '__publisher__', label: 'Publisher', tenantId: myPlatformId }),\n * });\n * ```\n *\n * The returned entry flows through `toAccount` just like a `lookup` hit.\n * When omitted the helper returns `null` for ref-less calls — handlers can\n * narrow on `ctx.account === undefined` and fall back to platform-level\n * config from their closure.\n *\n * **Ref-less calls (singleton from options):**\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: (_ref, ctx) => ({\n * id: '__publisher__',\n * name: 'Publisher',\n * tenant_id: deriveTenant(ctx?.authInfo),\n * }),\n * });\n * ```\n *\n * **Ref-less calls (auth-derived lookup).** When the singleton should be\n * derived from the caller's principal and needs to call back into `lookup`,\n * use the spread-override pattern instead:\n *\n * ```ts\n * const base = createRosterAccountStore({ lookup, toAccount });\n * const accounts: AccountStore<MyMeta> = {\n * ...base,\n * resolve: async (ref, ctx) => {\n * if (ref === undefined) {\n * const id = deriveAccountIdFromAuth(ctx?.authInfo);\n * return id ? base.resolve({ account_id: id }, ctx) : null;\n * }\n * return base.resolve(ref, ctx);\n * },\n * };\n * ```\n *\n * **Hybrid roster + buyer-updatable fields.** Some publisher-curated\n * platforms (GAM, FreeWheel, several retail-media networks) let the buyer\n * PATCH a narrow set of fields — billing contact, AP email, agency-of-record\n * cert — on a publisher-provisioned account, while keeping creation and\n * commercial terms (credit limit, rate card) read-only to the buyer. Compose\n * a partial `upsert` over the roster store and gate it on a field allowlist:\n *\n * ```ts\n * const BUYER_WRITABLE = new Set(['billing_entity', 'setup']);\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * upsert: async (refs, ctx) => refs.map(r => applyBuyerPatch(r, BUYER_WRITABLE, ctx)),\n * };\n * ```\n *\n * **Brand-arm refs return `null`.** Buyers who pass `{ brand, operator }`\n * (no `account_id`) hit the framework's `ACCOUNT_NOT_FOUND` envelope. The\n * helper cannot synthesize `INVALID_REQUEST` from inside `resolve` — if your\n * platform needs to reject brand-arm refs as a wire-shape error rather than\n * a not-found, wrap `resolve` and throw `AdcpError('INVALID_REQUEST', { field:\n * 'account.brand' })` before delegating to the helper.\n *\n * @example In-memory roster (tests, small fixed configs):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: (id) => roster.get(id),\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: 'active',\n * ctx_metadata: { upstreamId: row.upstream_id },\n * }),\n * });\n * ```\n *\n * @example Postgres-backed roster (publisher with admin UI):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: async (id, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * return await db.oneOrNone(\n * 'SELECT * FROM storefront_accounts WHERE id = $1 AND tenant = $2',\n * [id, tenantId],\n * );\n * },\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: row.status,\n * brand: row.brand,\n * operator: row.operator,\n * ctx_metadata: { tenant: row.tenant, upstreamRef: row.upstream_ref },\n * }),\n * list: async (filter, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * const rows = await db.any(buildListQuery(filter, tenantId));\n * return { items: rows, nextCursor: nextCursorFor(rows, filter) };\n * },\n * });\n * ```\n *\n * @public\n */\nexport function createRosterAccountStore<TRosterEntry, TCtxMeta = Record<string, unknown>>(\n options: RosterAccountStoreOptions<TRosterEntry, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolution: 'explicit',\n\n async resolve(ref: AccountReference | undefined, ctx?: ResolveContext): Promise<Account<TCtxMeta> | null> {\n const accountId = refAccountId(ref);\n if (accountId !== undefined) {\n const entry = await options.lookup(accountId, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // ref is undefined (no account field on wire) — delegate to resolveWithoutRef if provided.\n // Brand+operator refs (ref !== undefined but no account_id) fall through to null below;\n // resolveWithoutRef is intentionally not called for them.\n if (ref === undefined && options.resolveWithoutRef !== undefined) {\n const entry = await options.resolveWithoutRef(undefined, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // brand+operator-shaped refs (no account_id) and unhandled ref-less calls → null.\n // Adopters who need brand-arm resolution or auth-derived ref-less lookup wrap\n // `resolve` via spread — see the JSDoc on `createRosterAccountStore`.\n return null;\n },\n };\n\n if (options.list !== undefined) {\n const adopterList = options.list;\n store.list = async (filter, ctx) => {\n const page = await adopterList(filter, ctx);\n return {\n items: page.items.map(entry => options.toAccount(entry, ctx)),\n ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),\n };\n };\n }\n\n return store;\n}\n"],"mappings":"AA6CA,SAAS,oBAAoB;AA4PtB,SAAS,yBACd,SACwB;AACxB,QAAM,QAAgC;AAAA,IACpC,YAAY;AAAA,IAEZ,MAAM,QAAQ,KAAmC,KAAyD;AACxG,YAAM,YAAY,aAAa,GAAG;AAClC,UAAI,cAAc,QAAW;AAC3B,cAAM,QAAQ,MAAM,QAAQ,OAAO,WAAW,GAAG;AACjD,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,UAAI,QAAQ,UAAa,QAAQ,sBAAsB,QAAW;AAChE,cAAM,QAAQ,MAAM,QAAQ,kBAAkB,QAAW,GAAG;AAC5D,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,QAAQ,SAAS,QAAW;AAC9B,UAAM,cAAc,QAAQ;AAC5B,UAAM,OAAO,OAAO,QAAQ,QAAQ;AAClC,YAAM,OAAO,MAAM,YAAY,QAAQ,GAAG;AAC1C,aAAO;AAAA,QACL,OAAO,KAAK,MAAM,IAAI,WAAS,QAAQ,UAAU,OAAO,GAAG,CAAC;AAAA,QAC5D,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/adapters/roster-account-store.ts"],"sourcesContent":["/**\n * Roster-backed `AccountStore` factory for `resolution: 'explicit'` platforms\n * where the publisher curates accounts out-of-band (admin UI, config file,\n * publisher-managed DB row) and buyers pass `account_id` on every request.\n *\n * Pairs with the existing reference adapters. Pick by asking *who creates the\n * account?*:\n * - **Buyer self-onboards via `sync_accounts`** → {@link InMemoryImplicitAccountStore}\n * (Shape A, `resolution: 'implicit'`). Framework owns persistence; the\n * buyer's first request to a tenant-scoped tool resolves from a prior\n * sync. LinkedIn, some retail-media operators.\n * - **Upstream OAuth API owns the roster** → {@link createOAuthPassthroughResolver}\n * (Shape B, `resolution: 'explicit'`). Returns just the `resolve` function;\n * adopter composes into an AccountStore. Snap, Meta, TikTok — anywhere\n * the buyer's bearer is the lookup key for an upstream `/me/adaccounts`.\n * - **Publisher ops curates the roster out-of-band** → `createRosterAccountStore`\n * (this file, Shape C, `resolution: 'explicit'`). Adopter keeps the\n * persistence layer (storefront table, admin-UI-managed JSON column,\n * in-memory map); the SDK provides the AccountStore plumbing. Most\n * SSPs, broadcasters, and retail-media networks where AE/CSM provisions\n * the account in an internal admin tool before the buyer ever calls.\n * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore`\n * (Shape D, `resolution: 'derived'`). Single-tenant agents\n * (audiostack, flashtalking, single-namespace retail-media) where there\n * is no `account_id` on the wire and every call authenticates with the\n * per-request bearer.\n *\n * Design notes:\n * - Lookup is a point function, not a roster getter. An adopter with\n * thousands of accounts per tenant in Postgres can issue a SELECT WHERE\n * id = $1 instead of materializing the full array on every request.\n * - `list` is opt-in. Omit it and the framework emits UNSUPPORTED_FEATURE\n * for `list_accounts` calls (matches `AccountStore.list?` semantics).\n * Provide it and the adopter pushes filter+page down to whatever index\n * they have.\n * - No `upsert`, `reportUsage`, `getAccountFinancials`, or `refreshToken`.\n * Adopters who need those compose the helper output:\n * `{ ...createRosterAccountStore(...), refreshToken: async (a) => { ... } }`.\n *\n * @see docs/guides/account-resolution.md\n * @public\n */\n\nimport type { AccountReference, ListAccountsRequest } from '../types/tools.generated';\nimport type { Account, AccountStore, ResolveContext } from '../server/decisioning/account';\nimport { refAccountId } from '../server/decisioning/account';\nimport type { CursorPage } from '../server/decisioning/pagination';\n\n/**\n * Options for {@link createRosterAccountStore}.\n *\n * @public\n */\nexport interface RosterAccountStoreOptions<TRosterEntry, TCtxMeta = Record<string, unknown>> {\n /**\n * Point-lookup against the adopter's roster source. Return the entry for\n * a given `account_id`, or `undefined` when no row matches.\n *\n * Called once per `resolve()` with an `account_id`-shaped reference. Wire\n * shape `{ brand, operator }` and missing refs do NOT call `lookup`; see\n * the resolve behavior below.\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and projects to `ACCOUNT_NOT_FOUND`.\n */\n lookup: (\n accountId: string,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n\n /**\n * Convert a roster entry to the framework's `Account<TCtxMeta>` shape.\n *\n * The adopter's roster row typically lacks `ctx_metadata` (which is a\n * framework-internal field). This mapper is where the adopter populates\n * it — upstream IDs, per-account caches, anything specialism methods will\n * read off `ctx.account.ctx_metadata` later.\n *\n * **DO NOT put credentials in `ctx_metadata`.** See\n * `docs/guides/CTX-METADATA-SAFETY.md` for the rationale and the\n * recommended re-derive-per-request pattern (read tokens off\n * `ctx.authInfo` instead).\n *\n * Adopters MAY omit `authInfo` from the returned `Account` — the\n * framework auto-attaches the principal from `ctx.authInfo` when absent.\n */\n toAccount: (entry: TRosterEntry, ctx: ResolveContext | undefined) => Account<TCtxMeta>;\n\n /**\n * Optional `list_accounts` implementation. The adopter receives the wire\n * filter + cursor request and returns a page of roster entries; the\n * helper threads each entry through `toAccount` before returning.\n *\n * Omit to leave `list_accounts` unimplemented (framework returns\n * `UNSUPPORTED_FEATURE`).\n *\n * **Push filter + pagination down.** The naive shape — fetch the full\n * roster, filter+slice in memory — works for tens of accounts per tenant\n * but does not scale. Adopters with a Postgres-backed roster should map\n * `request.account` / `request.status` / `request.sandbox` and\n * `request.pagination.{cursor,max_results}` to a SQL query. The helper\n * does NOT post-filter; the adopter is the source of truth.\n *\n * **No default status filter.** `request.status` is `undefined` unless the\n * buyer passed it. If you want production callers to see only `active` +\n * `pending_approval` by default, apply that fallback inside your query —\n * the helper passes `request` through verbatim.\n */\n list?: (\n request: ListAccountsRequest,\n ctx: ResolveContext | undefined\n ) => CursorPage<TRosterEntry> | Promise<CursorPage<TRosterEntry>>;\n\n /**\n * Handle ref-less `accounts.resolve(undefined, ctx)` calls — invoked by\n * `list_creative_formats`, `provide_performance_feedback`, `preview_creative`,\n * and any discovery-phase tool that does not require a buyer-selected account.\n *\n * Return a synthetic publisher-wide roster entry; `toAccount` is applied to\n * it before the result is returned to the framework. Return `undefined` to\n * fall back to `null` (same as omitting this option — `ctx.account` is\n * `undefined` in the handler).\n *\n * The `ref` parameter is always `undefined` here — it is present for\n * signature parity with the framework's `resolve(ref, ctx)` shape so\n * code-generation tools produce consistent two-arg handler signatures.\n * Ignore it (use `(_ref, ctx) => ...`).\n *\n * Throw to signal a transient upstream failure (DB outage, network blip).\n * The framework projects to `SERVICE_UNAVAILABLE`. Returning `undefined`\n * is the canonical not-found path and returns `null` to the framework.\n *\n * **Brand+operator refs are not routed here.** A `{ brand, operator }` ref\n * without an `account_id` still falls through to `null`. Adopters who need\n * to handle brand-arm refs must override `resolve` on the returned store via\n * a spread.\n *\n * **`toAccount` is called on your return value** with the same `ctx`,\n * identical to the `lookup` path. Ensure your synthesized entry satisfies\n * all required fields of `TRosterEntry`.\n *\n * **Auth-derived lookup.** If your singleton should be derived from\n * `ctx.authInfo` (e.g. a per-tenant publisher account keyed on the OAuth\n * client), and that derivation needs to re-invoke `lookup`, use the\n * spread-override pattern on the returned store instead — a `resolveWithoutRef`\n * that calls back into `lookup` is more naturally expressed as a `resolve`\n * override so the call graph stays flat.\n *\n * **If you also override `resolve` on the spread**, `resolveWithoutRef` is\n * not called — the spread override takes precedence.\n *\n * Omit when `ctx.account === undefined` is acceptable for ref-less tools\n * (handlers can narrow on it and fall back to platform-level config from\n * their closure).\n */\n resolveWithoutRef?: (\n ref: undefined,\n ctx: ResolveContext | undefined\n ) => TRosterEntry | undefined | Promise<TRosterEntry | undefined>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` from an adopter-supplied roster source.\n *\n * The adopter brings persistence (DB row, admin-UI-managed JSON column,\n * in-memory Map, file). The helper provides:\n * - `resolution: 'explicit'` declaration\n * - `account_id`-arm dispatch from the wire reference\n * - Mapping from roster entry → `Account<TCtxMeta>` via `toAccount`\n * - Optional `list_accounts` plumbing with cursor envelope passthrough\n * - `null` return for `{ brand, operator }`-shaped refs and ref-less\n * calls (publisher-curated platforms expect explicit ids)\n *\n * Adopters who need `upsert` (buyer-driven write paths via `sync_accounts`),\n * `refreshToken`, `reportUsage`, or `getAccountFinancials` compose on top\n * of the returned store with a spread:\n *\n * ```ts\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * refreshToken: async (account) => myUpstream.refresh(account),\n * };\n * ```\n *\n * **Ref-less calls (singleton fallback).** `list_creative_formats`,\n * `preview_creative`, and `provide_performance_feedback` call\n * `accounts.resolve(undefined, ctx)`. Use `resolveWithoutRef` when your\n * platform needs a synthetic publisher-wide account for these tools:\n *\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: () => ({ id: '__publisher__', label: 'Publisher', tenantId: myPlatformId }),\n * });\n * ```\n *\n * The returned entry flows through `toAccount` just like a `lookup` hit.\n * When omitted the helper returns `null` for ref-less calls — handlers can\n * narrow on `ctx.account === undefined` and fall back to platform-level\n * config from their closure.\n *\n * **Ref-less calls (singleton from options):**\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup,\n * toAccount,\n * resolveWithoutRef: (_ref, ctx) => ({\n * id: '__publisher__',\n * name: 'Publisher',\n * tenant_id: deriveTenant(ctx?.authInfo),\n * }),\n * });\n * ```\n *\n * **Ref-less calls (auth-derived lookup).** When the singleton should be\n * derived from the caller's principal and needs to call back into `lookup`,\n * use the spread-override pattern instead:\n *\n * ```ts\n * const base = createRosterAccountStore({ lookup, toAccount });\n * const accounts: AccountStore<MyMeta> = {\n * ...base,\n * resolve: async (ref, ctx) => {\n * if (ref === undefined) {\n * const id = deriveAccountIdFromAuth(ctx?.authInfo);\n * return id ? base.resolve({ account_id: id }, ctx) : null;\n * }\n * return base.resolve(ref, ctx);\n * },\n * };\n * ```\n *\n * **Hybrid roster + buyer-updatable fields.** Some publisher-curated\n * platforms (GAM, FreeWheel, several retail-media networks) let the buyer\n * PATCH a narrow set of fields — billing contact, AP email, agency-of-record\n * cert — on a publisher-provisioned account, while keeping creation and\n * commercial terms (credit limit, rate card) read-only to the buyer. Compose\n * a partial `upsert` over the roster store and gate it on a field allowlist:\n *\n * ```ts\n * const BUYER_WRITABLE = new Set(['billing_entity', 'setup']);\n * const accounts: AccountStore<MyMeta> = {\n * ...createRosterAccountStore({ lookup, toAccount }),\n * upsert: async (refs, ctx) => refs.map(r => applyBuyerPatch(r, BUYER_WRITABLE, ctx)),\n * };\n * ```\n *\n * **Brand-arm refs return `null`.** Buyers who pass `{ brand, operator }`\n * (no `account_id`) hit the framework's `ACCOUNT_NOT_FOUND` envelope. The\n * helper cannot synthesize `INVALID_REQUEST` from inside `resolve` — if your\n * platform needs to reject brand-arm refs as a wire-shape error rather than\n * a not-found, wrap `resolve` and throw `AdcpError('INVALID_REQUEST', { field:\n * 'account.brand' })` before delegating to the helper.\n *\n * @example In-memory roster (tests, small fixed configs):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: (id) => roster.get(id),\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: 'active',\n * ctx_metadata: { upstreamId: row.upstream_id },\n * }),\n * });\n * ```\n *\n * @example Postgres-backed roster (publisher with admin UI):\n * ```ts\n * const accounts = createRosterAccountStore({\n * lookup: async (id, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * return await db.oneOrNone(\n * 'SELECT * FROM storefront_accounts WHERE id = $1 AND tenant = $2',\n * [id, tenantId],\n * );\n * },\n * toAccount: (row) => ({\n * id: row.id,\n * name: row.name,\n * status: row.status,\n * brand: row.brand,\n * operator: row.operator,\n * ctx_metadata: { tenant: row.tenant, upstreamRef: row.upstream_ref },\n * }),\n * list: async (filter, ctx) => {\n * const tenantId = deriveTenant(ctx?.authInfo);\n * const rows = await db.any(buildListQuery(filter, tenantId));\n * return { items: rows, nextCursor: nextCursorFor(rows, filter) };\n * },\n * });\n * ```\n *\n * @public\n */\nexport function createRosterAccountStore<TRosterEntry, TCtxMeta = Record<string, unknown>>(\n options: RosterAccountStoreOptions<TRosterEntry, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolution: 'explicit',\n\n async resolve(ref: AccountReference | undefined, ctx?: ResolveContext): Promise<Account<TCtxMeta> | null> {\n const accountId = refAccountId(ref);\n if (accountId !== undefined) {\n const entry = await options.lookup(accountId, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // ref is undefined (no account field on wire) — delegate to resolveWithoutRef if provided.\n // Brand+operator refs (ref !== undefined but no account_id) fall through to null below;\n // resolveWithoutRef is intentionally not called for them.\n if (ref === undefined && options.resolveWithoutRef !== undefined) {\n const entry = await options.resolveWithoutRef(undefined, ctx);\n return entry === undefined ? null : options.toAccount(entry, ctx);\n }\n\n // brand+operator-shaped refs (no account_id) and unhandled ref-less calls → null.\n // Adopters who need brand-arm resolution or auth-derived ref-less lookup wrap\n // `resolve` via spread — see the JSDoc on `createRosterAccountStore`.\n return null;\n },\n };\n\n if (options.list !== undefined) {\n const adopterList = options.list;\n store.list = async (filter, ctx) => {\n const page = await adopterList(filter, ctx);\n return {\n items: page.items.map(entry => options.toAccount(entry, ctx)),\n ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),\n ...(page.totalCount !== undefined && { totalCount: page.totalCount }),\n };\n };\n }\n\n return store;\n}\n"],"mappings":"AA6CA,SAAS,oBAAoB;AA4PtB,SAAS,yBACd,SACwB;AACxB,QAAM,QAAgC;AAAA,IACpC,YAAY;AAAA,IAEZ,MAAM,QAAQ,KAAmC,KAAyD;AACxG,YAAM,YAAY,aAAa,GAAG;AAClC,UAAI,cAAc,QAAW;AAC3B,cAAM,QAAQ,MAAM,QAAQ,OAAO,WAAW,GAAG;AACjD,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,UAAI,QAAQ,UAAa,QAAQ,sBAAsB,QAAW;AAChE,cAAM,QAAQ,MAAM,QAAQ,kBAAkB,QAAW,GAAG;AAC5D,eAAO,UAAU,SAAY,OAAO,QAAQ,UAAU,OAAO,GAAG;AAAA,MAClE;AAKA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,QAAQ,SAAS,QAAW;AAC9B,UAAM,cAAc,QAAQ;AAC5B,UAAM,OAAO,OAAO,QAAQ,QAAQ;AAClC,YAAM,OAAO,MAAM,YAAY,QAAQ,GAAG;AAC1C,aAAO;AAAA,QACL,OAAO,KAAK,MAAM,IAAI,WAAS,QAAQ,UAAU,OAAO,GAAG,CAAC;AAAA,QAC5D,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,QACnE,GAAI,KAAK,eAAe,UAAa,EAAE,YAAY,KAAK,WAAW;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"address-guards.d.ts","sourceRoot":"","sources":["../../../src/lib/net/address-guards.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"address-guards.d.ts","sourceRoot":"","sources":["../../../src/lib/net/address-guards.ts"],"names":[],"mappings":"AAqFA;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CASvD"}
|
|
@@ -49,6 +49,10 @@ privateIp.addSubnet("192.168.0.0", 16, "ipv4");
|
|
|
49
49
|
privateIp.addSubnet("224.0.0.0", 4, "ipv4");
|
|
50
50
|
privateIp.addSubnet("192.0.0.0", 24, "ipv4");
|
|
51
51
|
privateIp.addSubnet("192.88.99.0", 24, "ipv4");
|
|
52
|
+
privateIp.addSubnet("192.0.2.0", 24, "ipv4");
|
|
53
|
+
privateIp.addSubnet("198.18.0.0", 15, "ipv4");
|
|
54
|
+
privateIp.addSubnet("198.51.100.0", 24, "ipv4");
|
|
55
|
+
privateIp.addSubnet("203.0.113.0", 24, "ipv4");
|
|
52
56
|
privateIp.addSubnet("240.0.0.0", 4, "ipv4");
|
|
53
57
|
privateIp.addAddress("255.255.255.255", "ipv4");
|
|
54
58
|
privateIp.addAddress("::", "ipv6");
|
|
@@ -56,6 +60,8 @@ privateIp.addAddress("::1", "ipv6");
|
|
|
56
60
|
privateIp.addSubnet("fe80::", 10, "ipv6");
|
|
57
61
|
privateIp.addSubnet("fc00::", 7, "ipv6");
|
|
58
62
|
privateIp.addSubnet("ff00::", 8, "ipv6");
|
|
63
|
+
privateIp.addSubnet("100::", 64, "ipv6");
|
|
64
|
+
privateIp.addSubnet("2001:db8::", 32, "ipv6");
|
|
59
65
|
privateIp.addSubnet("64:ff9b::", 96, "ipv6");
|
|
60
66
|
privateIp.addSubnet("2002::", 16, "ipv6");
|
|
61
67
|
function isAlwaysBlocked(address) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/net/address-guards.ts"],"sourcesContent":["/**\n * IP address classification for SSRF defense.\n *\n * Two tiers of blocking:\n * - {@link isAlwaysBlocked}: link-local + cloud metadata endpoints (IMDS).\n * Refused even when the caller opts into private networks (dev loops).\n * - {@link isPrivateIp}: RFC 1918, loopback, CGNAT, IPv6 ULA/link-local,\n * multicast, broadcast, unspecified, plus defense-in-depth on IPv6\n * wrappers (NAT64 well-known prefix, 6to4) so a v4-in-v6 address can't\n * sneak a private target past the classifier. Refused by default; allowed\n * when the caller passes `allowPrivateIp: true` (storyboard runner's\n * `--allow-http`).\n *\n * Classifiers normalize before matching:\n * - Zone IDs (`%eth0`) are stripped — they're a host-local concept, not part\n * of the address, and Node's IP parsers don't accept them.\n * - Surrounding URL brackets (`[::1]`) are stripped — `URL.hostname` returns\n * bracketed form for IPv6 literals; classifiers need bare input.\n * - IPv4-mapped IPv6 is resolved natively by `BlockList` — `::ffff:10.0.0.1`\n * matches the `10.0.0.0/8` subnet regardless of textual form\n * (`0:0:0:0:0:ffff:a.b.c.d` works too).\n */\nimport { BlockList, isIP } from 'net';\n\nfunction normalize(address: string): { addr: string; family: 'ipv4' | 'ipv6' } | null {\n // Strip surrounding brackets (URL-hostname form) and zone ID.\n let bare = address;\n if (bare.startsWith('[') && bare.endsWith(']')) bare = bare.slice(1, -1);\n const pctIdx = bare.indexOf('%');\n if (pctIdx >= 0) bare = bare.slice(0, pctIdx);\n const family = isIP(bare);\n if (family === 4) return { addr: bare, family: 'ipv4' };\n if (family === 6) return { addr: bare, family: 'ipv6' };\n return null;\n}\n\n// Addresses blocked even when the dev opt-in `allowPrivateIp` is set. Cloud\n// metadata services live at 169.254.169.254 and leak credentials if reached;\n// IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the host's\n// local segment.\nconst alwaysBlocked = new BlockList();\nalwaysBlocked.addSubnet('169.254.0.0', 16, 'ipv4');\nalwaysBlocked.addSubnet('fe80::', 10, 'ipv6');\n// Oracle Cloud IMDS lives at 192.0.0.192 (inside RFC 6890's 192.0.0.0/24\n// IETF-protocol assignments) rather than the 169.254.0.0/16 everyone else\n// uses, so it needs its own entry to be refused even under the private opt-in.\nalwaysBlocked.addAddress('192.0.0.192', 'ipv4');\n\n// Private, loopback, multicast, and reserved ranges. Defense-in-depth adds the\n// NAT64 well-known prefix (`64:ff9b::/96`) and 6to4 (`2002::/16`) so a\n// wrapped-v4 address can't bypass the classifier by choosing a representation\n// BlockList doesn't natively canonicalize.\nconst privateIp = new BlockList();\n// v4 — BlockList handles IPv4-mapped IPv6 (`::ffff:a.b.c.d`) against these\n// subnets automatically per Node's check semantics.\nprivateIp.addSubnet('0.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('10.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('127.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC 6598 CGNAT\nprivateIp.addSubnet('169.254.0.0', 16, 'ipv4');\nprivateIp.addSubnet('172.16.0.0', 12, 'ipv4');\nprivateIp.addSubnet('192.168.0.0', 16, 'ipv4');\nprivateIp.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast\nprivateIp.addSubnet('192.0.0.0', 24, 'ipv4'); // RFC 6890 IETF protocol assignments (incl. Oracle IMDS)\nprivateIp.addSubnet('192.88.99.0', 24, 'ipv4'); // 6to4 relay anycast (RFC 7526, deprecated)\nprivateIp.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved for future use\nprivateIp.addAddress('255.255.255.255', 'ipv4'); // limited broadcast\n// v6\nprivateIp.addAddress('::', 'ipv6'); // unspecified\nprivateIp.addAddress('::1', 'ipv6'); // loopback\nprivateIp.addSubnet('fe80::', 10, 'ipv6'); // link-local\nprivateIp.addSubnet('fc00::', 7, 'ipv6'); // ULA\nprivateIp.addSubnet('ff00::', 8, 'ipv6'); // multicast\n// Wrapper prefixes — refuse unconditionally. Tunnels at the caller's edge can\n// translate these into private targets we can't see; safer to refuse than to\n// hope the gateway is configured the way we expect.\nprivateIp.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known\nprivateIp.addSubnet('2002::', 16, 'ipv6'); // 6to4\n\n/**\n * Addresses blocked even when `allowPrivateIp` is on. Cloud metadata services\n * (AWS/GCP/Azure IMDS) live at 169.254.169.254 and would exfiltrate\n * credentials if a CI runner or long-lived server followed an attacker URL to\n * them. IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the\n * host's local segment.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isAlwaysBlocked(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return alwaysBlocked.check(n.addr, n.family);\n}\n\n/**\n * Reject loopback, link-local, RFC 1918 private ranges, CGNAT (RFC 6598),\n * broadcast, multicast, the unspecified address, NAT64/6to4 wrapper prefixes,\n * and IPv6 equivalents. BlockList handles IPv4-mapped IPv6 canonicalization\n * natively so `::ffff:10.0.0.1` is matched against the v4 rule set.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isPrivateIp(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return privateIp.check(n.addr, n.family);\n}\n\n/**\n * Best-effort check that a URL targets a development/private host, without\n * doing a DNS lookup. Matches loopback hostnames (`localhost`) and any IP\n * literal that {@link isPrivateIp} would reject. Public domain names always\n * return `false`.\n *\n * Used by higher layers that need to inherit the operator's \"private is OK\"\n * trust from a primary probe and propagate it to same-origin chain hops —\n * callers that pass this flag into `ssrfSafeFetch` should do so only when\n * they've already decided the target origin is trusted.\n *\n * Returns `false` on unparseable inputs.\n */\nexport function isLikelyPrivateUrl(url: string): boolean {\n try {\n const u = new URL(url);\n const host = u.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n if (host === 'localhost') return true;\n return isPrivateIp(host);\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBA,iBAAgC;AAEhC,SAAS,UAAU,SAAmE;AAEpF,MAAI,OAAO;AACX,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,QAAM,SAAS,KAAK,QAAQ,GAAG;AAC/B,MAAI,UAAU,EAAG,QAAO,KAAK,MAAM,GAAG,MAAM;AAC5C,QAAM,aAAS,iBAAK,IAAI;AACxB,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,SAAO;AACT;AAMA,MAAM,gBAAgB,IAAI,qBAAU;AACpC,cAAc,UAAU,eAAe,IAAI,MAAM;AACjD,cAAc,UAAU,UAAU,IAAI,MAAM;AAI5C,cAAc,WAAW,eAAe,MAAM;AAM9C,MAAM,YAAY,IAAI,qBAAU;AAGhC,UAAU,UAAU,WAAW,GAAG,MAAM;AACxC,UAAU,UAAU,YAAY,GAAG,MAAM;AACzC,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,WAAW,mBAAmB,MAAM;AAE9C,UAAU,WAAW,MAAM,MAAM;AACjC,UAAU,WAAW,OAAO,MAAM;AAClC,UAAU,UAAU,UAAU,IAAI,MAAM;AACxC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,UAAU,GAAG,MAAM;
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/net/address-guards.ts"],"sourcesContent":["/**\n * IP address classification for SSRF defense.\n *\n * Two tiers of blocking:\n * - {@link isAlwaysBlocked}: link-local + cloud metadata endpoints (IMDS).\n * Refused even when the caller opts into private networks (dev loops).\n * - {@link isPrivateIp}: RFC 1918, loopback, CGNAT, IPv6 ULA/link-local,\n * multicast, broadcast, unspecified, plus defense-in-depth on IPv6\n * wrappers (NAT64 well-known prefix, 6to4) so a v4-in-v6 address can't\n * sneak a private target past the classifier. Refused by default; allowed\n * when the caller passes `allowPrivateIp: true` (storyboard runner's\n * `--allow-http`).\n *\n * Classifiers normalize before matching:\n * - Zone IDs (`%eth0`) are stripped — they're a host-local concept, not part\n * of the address, and Node's IP parsers don't accept them.\n * - Surrounding URL brackets (`[::1]`) are stripped — `URL.hostname` returns\n * bracketed form for IPv6 literals; classifiers need bare input.\n * - IPv4-mapped IPv6 is resolved natively by `BlockList` — `::ffff:10.0.0.1`\n * matches the `10.0.0.0/8` subnet regardless of textual form\n * (`0:0:0:0:0:ffff:a.b.c.d` works too).\n */\nimport { BlockList, isIP } from 'net';\n\nfunction normalize(address: string): { addr: string; family: 'ipv4' | 'ipv6' } | null {\n // Strip surrounding brackets (URL-hostname form) and zone ID.\n let bare = address;\n if (bare.startsWith('[') && bare.endsWith(']')) bare = bare.slice(1, -1);\n const pctIdx = bare.indexOf('%');\n if (pctIdx >= 0) bare = bare.slice(0, pctIdx);\n const family = isIP(bare);\n if (family === 4) return { addr: bare, family: 'ipv4' };\n if (family === 6) return { addr: bare, family: 'ipv6' };\n return null;\n}\n\n// Addresses blocked even when the dev opt-in `allowPrivateIp` is set. Cloud\n// metadata services live at 169.254.169.254 and leak credentials if reached;\n// IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the host's\n// local segment.\nconst alwaysBlocked = new BlockList();\nalwaysBlocked.addSubnet('169.254.0.0', 16, 'ipv4');\nalwaysBlocked.addSubnet('fe80::', 10, 'ipv6');\n// Oracle Cloud IMDS lives at 192.0.0.192 (inside RFC 6890's 192.0.0.0/24\n// IETF-protocol assignments) rather than the 169.254.0.0/16 everyone else\n// uses, so it needs its own entry to be refused even under the private opt-in.\nalwaysBlocked.addAddress('192.0.0.192', 'ipv4');\n\n// Private, loopback, multicast, and reserved ranges. Defense-in-depth adds the\n// NAT64 well-known prefix (`64:ff9b::/96`) and 6to4 (`2002::/16`) so a\n// wrapped-v4 address can't bypass the classifier by choosing a representation\n// BlockList doesn't natively canonicalize.\nconst privateIp = new BlockList();\n// v4 — BlockList handles IPv4-mapped IPv6 (`::ffff:a.b.c.d`) against these\n// subnets automatically per Node's check semantics.\nprivateIp.addSubnet('0.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('10.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('127.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC 6598 CGNAT\nprivateIp.addSubnet('169.254.0.0', 16, 'ipv4');\nprivateIp.addSubnet('172.16.0.0', 12, 'ipv4');\nprivateIp.addSubnet('192.168.0.0', 16, 'ipv4');\nprivateIp.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast\nprivateIp.addSubnet('192.0.0.0', 24, 'ipv4'); // RFC 6890 IETF protocol assignments (incl. Oracle IMDS)\nprivateIp.addSubnet('192.88.99.0', 24, 'ipv4'); // 6to4 relay anycast (RFC 7526, deprecated)\nprivateIp.addSubnet('192.0.2.0', 24, 'ipv4'); // documentation (TEST-NET-1)\nprivateIp.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking\nprivateIp.addSubnet('198.51.100.0', 24, 'ipv4'); // documentation (TEST-NET-2)\nprivateIp.addSubnet('203.0.113.0', 24, 'ipv4'); // documentation (TEST-NET-3)\nprivateIp.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved for future use\nprivateIp.addAddress('255.255.255.255', 'ipv4'); // limited broadcast\n// v6\nprivateIp.addAddress('::', 'ipv6'); // unspecified\nprivateIp.addAddress('::1', 'ipv6'); // loopback\nprivateIp.addSubnet('fe80::', 10, 'ipv6'); // link-local\nprivateIp.addSubnet('fc00::', 7, 'ipv6'); // ULA\nprivateIp.addSubnet('ff00::', 8, 'ipv6'); // multicast\nprivateIp.addSubnet('100::', 64, 'ipv6'); // discard-only (RFC 6666)\nprivateIp.addSubnet('2001:db8::', 32, 'ipv6'); // documentation\n// Wrapper prefixes — refuse unconditionally. Tunnels at the caller's edge can\n// translate these into private targets we can't see; safer to refuse than to\n// hope the gateway is configured the way we expect.\nprivateIp.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known\nprivateIp.addSubnet('2002::', 16, 'ipv6'); // 6to4\n\n/**\n * Addresses blocked even when `allowPrivateIp` is on. Cloud metadata services\n * (AWS/GCP/Azure IMDS) live at 169.254.169.254 and would exfiltrate\n * credentials if a CI runner or long-lived server followed an attacker URL to\n * them. IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the\n * host's local segment.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isAlwaysBlocked(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return alwaysBlocked.check(n.addr, n.family);\n}\n\n/**\n * Reject loopback, link-local, RFC 1918 private ranges, CGNAT (RFC 6598),\n * broadcast, multicast, the unspecified address, NAT64/6to4 wrapper prefixes,\n * and IPv6 equivalents. BlockList handles IPv4-mapped IPv6 canonicalization\n * natively so `::ffff:10.0.0.1` is matched against the v4 rule set.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isPrivateIp(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return privateIp.check(n.addr, n.family);\n}\n\n/**\n * Best-effort check that a URL targets a development/private host, without\n * doing a DNS lookup. Matches loopback hostnames (`localhost`) and any IP\n * literal that {@link isPrivateIp} would reject. Public domain names always\n * return `false`.\n *\n * Used by higher layers that need to inherit the operator's \"private is OK\"\n * trust from a primary probe and propagate it to same-origin chain hops —\n * callers that pass this flag into `ssrfSafeFetch` should do so only when\n * they've already decided the target origin is trusted.\n *\n * Returns `false` on unparseable inputs.\n */\nexport function isLikelyPrivateUrl(url: string): boolean {\n try {\n const u = new URL(url);\n const host = u.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n if (host === 'localhost') return true;\n return isPrivateIp(host);\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBA,iBAAgC;AAEhC,SAAS,UAAU,SAAmE;AAEpF,MAAI,OAAO;AACX,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,QAAM,SAAS,KAAK,QAAQ,GAAG;AAC/B,MAAI,UAAU,EAAG,QAAO,KAAK,MAAM,GAAG,MAAM;AAC5C,QAAM,aAAS,iBAAK,IAAI;AACxB,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,SAAO;AACT;AAMA,MAAM,gBAAgB,IAAI,qBAAU;AACpC,cAAc,UAAU,eAAe,IAAI,MAAM;AACjD,cAAc,UAAU,UAAU,IAAI,MAAM;AAI5C,cAAc,WAAW,eAAe,MAAM;AAM9C,MAAM,YAAY,IAAI,qBAAU;AAGhC,UAAU,UAAU,WAAW,GAAG,MAAM;AACxC,UAAU,UAAU,YAAY,GAAG,MAAM;AACzC,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,gBAAgB,IAAI,MAAM;AAC9C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,WAAW,mBAAmB,MAAM;AAE9C,UAAU,WAAW,MAAM,MAAM;AACjC,UAAU,WAAW,OAAO,MAAM;AAClC,UAAU,UAAU,UAAU,IAAI,MAAM;AACxC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,SAAS,IAAI,MAAM;AACvC,UAAU,UAAU,cAAc,IAAI,MAAM;AAI5C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,UAAU,IAAI,MAAM;AAWjC,SAAS,gBAAgB,SAA0B;AACxD,QAAM,IAAI,UAAU,OAAO;AAC3B,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,cAAc,MAAM,EAAE,MAAM,EAAE,MAAM;AAC7C;AAUO,SAAS,YAAY,SAA0B;AACpD,QAAM,IAAI,UAAU,OAAO;AAC3B,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,UAAU,MAAM,EAAE,MAAM,EAAE,MAAM;AACzC;AAeO,SAAS,mBAAmB,KAAsB;AACvD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,UAAM,OAAO,EAAE,SAAS,QAAQ,YAAY,EAAE,EAAE,YAAY;AAC5D,QAAI,SAAS,YAAa,QAAO;AACjC,WAAO,YAAY,IAAI;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
@@ -24,6 +24,10 @@ privateIp.addSubnet("192.168.0.0", 16, "ipv4");
|
|
|
24
24
|
privateIp.addSubnet("224.0.0.0", 4, "ipv4");
|
|
25
25
|
privateIp.addSubnet("192.0.0.0", 24, "ipv4");
|
|
26
26
|
privateIp.addSubnet("192.88.99.0", 24, "ipv4");
|
|
27
|
+
privateIp.addSubnet("192.0.2.0", 24, "ipv4");
|
|
28
|
+
privateIp.addSubnet("198.18.0.0", 15, "ipv4");
|
|
29
|
+
privateIp.addSubnet("198.51.100.0", 24, "ipv4");
|
|
30
|
+
privateIp.addSubnet("203.0.113.0", 24, "ipv4");
|
|
27
31
|
privateIp.addSubnet("240.0.0.0", 4, "ipv4");
|
|
28
32
|
privateIp.addAddress("255.255.255.255", "ipv4");
|
|
29
33
|
privateIp.addAddress("::", "ipv6");
|
|
@@ -31,6 +35,8 @@ privateIp.addAddress("::1", "ipv6");
|
|
|
31
35
|
privateIp.addSubnet("fe80::", 10, "ipv6");
|
|
32
36
|
privateIp.addSubnet("fc00::", 7, "ipv6");
|
|
33
37
|
privateIp.addSubnet("ff00::", 8, "ipv6");
|
|
38
|
+
privateIp.addSubnet("100::", 64, "ipv6");
|
|
39
|
+
privateIp.addSubnet("2001:db8::", 32, "ipv6");
|
|
34
40
|
privateIp.addSubnet("64:ff9b::", 96, "ipv6");
|
|
35
41
|
privateIp.addSubnet("2002::", 16, "ipv6");
|
|
36
42
|
function isAlwaysBlocked(address) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/net/address-guards.ts"],"sourcesContent":["/**\n * IP address classification for SSRF defense.\n *\n * Two tiers of blocking:\n * - {@link isAlwaysBlocked}: link-local + cloud metadata endpoints (IMDS).\n * Refused even when the caller opts into private networks (dev loops).\n * - {@link isPrivateIp}: RFC 1918, loopback, CGNAT, IPv6 ULA/link-local,\n * multicast, broadcast, unspecified, plus defense-in-depth on IPv6\n * wrappers (NAT64 well-known prefix, 6to4) so a v4-in-v6 address can't\n * sneak a private target past the classifier. Refused by default; allowed\n * when the caller passes `allowPrivateIp: true` (storyboard runner's\n * `--allow-http`).\n *\n * Classifiers normalize before matching:\n * - Zone IDs (`%eth0`) are stripped — they're a host-local concept, not part\n * of the address, and Node's IP parsers don't accept them.\n * - Surrounding URL brackets (`[::1]`) are stripped — `URL.hostname` returns\n * bracketed form for IPv6 literals; classifiers need bare input.\n * - IPv4-mapped IPv6 is resolved natively by `BlockList` — `::ffff:10.0.0.1`\n * matches the `10.0.0.0/8` subnet regardless of textual form\n * (`0:0:0:0:0:ffff:a.b.c.d` works too).\n */\nimport { BlockList, isIP } from 'net';\n\nfunction normalize(address: string): { addr: string; family: 'ipv4' | 'ipv6' } | null {\n // Strip surrounding brackets (URL-hostname form) and zone ID.\n let bare = address;\n if (bare.startsWith('[') && bare.endsWith(']')) bare = bare.slice(1, -1);\n const pctIdx = bare.indexOf('%');\n if (pctIdx >= 0) bare = bare.slice(0, pctIdx);\n const family = isIP(bare);\n if (family === 4) return { addr: bare, family: 'ipv4' };\n if (family === 6) return { addr: bare, family: 'ipv6' };\n return null;\n}\n\n// Addresses blocked even when the dev opt-in `allowPrivateIp` is set. Cloud\n// metadata services live at 169.254.169.254 and leak credentials if reached;\n// IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the host's\n// local segment.\nconst alwaysBlocked = new BlockList();\nalwaysBlocked.addSubnet('169.254.0.0', 16, 'ipv4');\nalwaysBlocked.addSubnet('fe80::', 10, 'ipv6');\n// Oracle Cloud IMDS lives at 192.0.0.192 (inside RFC 6890's 192.0.0.0/24\n// IETF-protocol assignments) rather than the 169.254.0.0/16 everyone else\n// uses, so it needs its own entry to be refused even under the private opt-in.\nalwaysBlocked.addAddress('192.0.0.192', 'ipv4');\n\n// Private, loopback, multicast, and reserved ranges. Defense-in-depth adds the\n// NAT64 well-known prefix (`64:ff9b::/96`) and 6to4 (`2002::/16`) so a\n// wrapped-v4 address can't bypass the classifier by choosing a representation\n// BlockList doesn't natively canonicalize.\nconst privateIp = new BlockList();\n// v4 — BlockList handles IPv4-mapped IPv6 (`::ffff:a.b.c.d`) against these\n// subnets automatically per Node's check semantics.\nprivateIp.addSubnet('0.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('10.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('127.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC 6598 CGNAT\nprivateIp.addSubnet('169.254.0.0', 16, 'ipv4');\nprivateIp.addSubnet('172.16.0.0', 12, 'ipv4');\nprivateIp.addSubnet('192.168.0.0', 16, 'ipv4');\nprivateIp.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast\nprivateIp.addSubnet('192.0.0.0', 24, 'ipv4'); // RFC 6890 IETF protocol assignments (incl. Oracle IMDS)\nprivateIp.addSubnet('192.88.99.0', 24, 'ipv4'); // 6to4 relay anycast (RFC 7526, deprecated)\nprivateIp.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved for future use\nprivateIp.addAddress('255.255.255.255', 'ipv4'); // limited broadcast\n// v6\nprivateIp.addAddress('::', 'ipv6'); // unspecified\nprivateIp.addAddress('::1', 'ipv6'); // loopback\nprivateIp.addSubnet('fe80::', 10, 'ipv6'); // link-local\nprivateIp.addSubnet('fc00::', 7, 'ipv6'); // ULA\nprivateIp.addSubnet('ff00::', 8, 'ipv6'); // multicast\n// Wrapper prefixes — refuse unconditionally. Tunnels at the caller's edge can\n// translate these into private targets we can't see; safer to refuse than to\n// hope the gateway is configured the way we expect.\nprivateIp.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known\nprivateIp.addSubnet('2002::', 16, 'ipv6'); // 6to4\n\n/**\n * Addresses blocked even when `allowPrivateIp` is on. Cloud metadata services\n * (AWS/GCP/Azure IMDS) live at 169.254.169.254 and would exfiltrate\n * credentials if a CI runner or long-lived server followed an attacker URL to\n * them. IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the\n * host's local segment.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isAlwaysBlocked(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return alwaysBlocked.check(n.addr, n.family);\n}\n\n/**\n * Reject loopback, link-local, RFC 1918 private ranges, CGNAT (RFC 6598),\n * broadcast, multicast, the unspecified address, NAT64/6to4 wrapper prefixes,\n * and IPv6 equivalents. BlockList handles IPv4-mapped IPv6 canonicalization\n * natively so `::ffff:10.0.0.1` is matched against the v4 rule set.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isPrivateIp(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return privateIp.check(n.addr, n.family);\n}\n\n/**\n * Best-effort check that a URL targets a development/private host, without\n * doing a DNS lookup. Matches loopback hostnames (`localhost`) and any IP\n * literal that {@link isPrivateIp} would reject. Public domain names always\n * return `false`.\n *\n * Used by higher layers that need to inherit the operator's \"private is OK\"\n * trust from a primary probe and propagate it to same-origin chain hops —\n * callers that pass this flag into `ssrfSafeFetch` should do so only when\n * they've already decided the target origin is trusted.\n *\n * Returns `false` on unparseable inputs.\n */\nexport function isLikelyPrivateUrl(url: string): boolean {\n try {\n const u = new URL(url);\n const host = u.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n if (host === 'localhost') return true;\n return isPrivateIp(host);\n } catch {\n return false;\n }\n}\n"],"mappings":"AAsBA,SAAS,WAAW,YAAY;AAEhC,SAAS,UAAU,SAAmE;AAEpF,MAAI,OAAO;AACX,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,QAAM,SAAS,KAAK,QAAQ,GAAG;AAC/B,MAAI,UAAU,EAAG,QAAO,KAAK,MAAM,GAAG,MAAM;AAC5C,QAAM,SAAS,KAAK,IAAI;AACxB,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,SAAO;AACT;AAMA,MAAM,gBAAgB,IAAI,UAAU;AACpC,cAAc,UAAU,eAAe,IAAI,MAAM;AACjD,cAAc,UAAU,UAAU,IAAI,MAAM;AAI5C,cAAc,WAAW,eAAe,MAAM;AAM9C,MAAM,YAAY,IAAI,UAAU;AAGhC,UAAU,UAAU,WAAW,GAAG,MAAM;AACxC,UAAU,UAAU,YAAY,GAAG,MAAM;AACzC,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,WAAW,mBAAmB,MAAM;AAE9C,UAAU,WAAW,MAAM,MAAM;AACjC,UAAU,WAAW,OAAO,MAAM;AAClC,UAAU,UAAU,UAAU,IAAI,MAAM;AACxC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,UAAU,GAAG,MAAM;
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/net/address-guards.ts"],"sourcesContent":["/**\n * IP address classification for SSRF defense.\n *\n * Two tiers of blocking:\n * - {@link isAlwaysBlocked}: link-local + cloud metadata endpoints (IMDS).\n * Refused even when the caller opts into private networks (dev loops).\n * - {@link isPrivateIp}: RFC 1918, loopback, CGNAT, IPv6 ULA/link-local,\n * multicast, broadcast, unspecified, plus defense-in-depth on IPv6\n * wrappers (NAT64 well-known prefix, 6to4) so a v4-in-v6 address can't\n * sneak a private target past the classifier. Refused by default; allowed\n * when the caller passes `allowPrivateIp: true` (storyboard runner's\n * `--allow-http`).\n *\n * Classifiers normalize before matching:\n * - Zone IDs (`%eth0`) are stripped — they're a host-local concept, not part\n * of the address, and Node's IP parsers don't accept them.\n * - Surrounding URL brackets (`[::1]`) are stripped — `URL.hostname` returns\n * bracketed form for IPv6 literals; classifiers need bare input.\n * - IPv4-mapped IPv6 is resolved natively by `BlockList` — `::ffff:10.0.0.1`\n * matches the `10.0.0.0/8` subnet regardless of textual form\n * (`0:0:0:0:0:ffff:a.b.c.d` works too).\n */\nimport { BlockList, isIP } from 'net';\n\nfunction normalize(address: string): { addr: string; family: 'ipv4' | 'ipv6' } | null {\n // Strip surrounding brackets (URL-hostname form) and zone ID.\n let bare = address;\n if (bare.startsWith('[') && bare.endsWith(']')) bare = bare.slice(1, -1);\n const pctIdx = bare.indexOf('%');\n if (pctIdx >= 0) bare = bare.slice(0, pctIdx);\n const family = isIP(bare);\n if (family === 4) return { addr: bare, family: 'ipv4' };\n if (family === 6) return { addr: bare, family: 'ipv6' };\n return null;\n}\n\n// Addresses blocked even when the dev opt-in `allowPrivateIp` is set. Cloud\n// metadata services live at 169.254.169.254 and leak credentials if reached;\n// IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the host's\n// local segment.\nconst alwaysBlocked = new BlockList();\nalwaysBlocked.addSubnet('169.254.0.0', 16, 'ipv4');\nalwaysBlocked.addSubnet('fe80::', 10, 'ipv6');\n// Oracle Cloud IMDS lives at 192.0.0.192 (inside RFC 6890's 192.0.0.0/24\n// IETF-protocol assignments) rather than the 169.254.0.0/16 everyone else\n// uses, so it needs its own entry to be refused even under the private opt-in.\nalwaysBlocked.addAddress('192.0.0.192', 'ipv4');\n\n// Private, loopback, multicast, and reserved ranges. Defense-in-depth adds the\n// NAT64 well-known prefix (`64:ff9b::/96`) and 6to4 (`2002::/16`) so a\n// wrapped-v4 address can't bypass the classifier by choosing a representation\n// BlockList doesn't natively canonicalize.\nconst privateIp = new BlockList();\n// v4 — BlockList handles IPv4-mapped IPv6 (`::ffff:a.b.c.d`) against these\n// subnets automatically per Node's check semantics.\nprivateIp.addSubnet('0.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('10.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('127.0.0.0', 8, 'ipv4');\nprivateIp.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC 6598 CGNAT\nprivateIp.addSubnet('169.254.0.0', 16, 'ipv4');\nprivateIp.addSubnet('172.16.0.0', 12, 'ipv4');\nprivateIp.addSubnet('192.168.0.0', 16, 'ipv4');\nprivateIp.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast\nprivateIp.addSubnet('192.0.0.0', 24, 'ipv4'); // RFC 6890 IETF protocol assignments (incl. Oracle IMDS)\nprivateIp.addSubnet('192.88.99.0', 24, 'ipv4'); // 6to4 relay anycast (RFC 7526, deprecated)\nprivateIp.addSubnet('192.0.2.0', 24, 'ipv4'); // documentation (TEST-NET-1)\nprivateIp.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking\nprivateIp.addSubnet('198.51.100.0', 24, 'ipv4'); // documentation (TEST-NET-2)\nprivateIp.addSubnet('203.0.113.0', 24, 'ipv4'); // documentation (TEST-NET-3)\nprivateIp.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved for future use\nprivateIp.addAddress('255.255.255.255', 'ipv4'); // limited broadcast\n// v6\nprivateIp.addAddress('::', 'ipv6'); // unspecified\nprivateIp.addAddress('::1', 'ipv6'); // loopback\nprivateIp.addSubnet('fe80::', 10, 'ipv6'); // link-local\nprivateIp.addSubnet('fc00::', 7, 'ipv6'); // ULA\nprivateIp.addSubnet('ff00::', 8, 'ipv6'); // multicast\nprivateIp.addSubnet('100::', 64, 'ipv6'); // discard-only (RFC 6666)\nprivateIp.addSubnet('2001:db8::', 32, 'ipv6'); // documentation\n// Wrapper prefixes — refuse unconditionally. Tunnels at the caller's edge can\n// translate these into private targets we can't see; safer to refuse than to\n// hope the gateway is configured the way we expect.\nprivateIp.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known\nprivateIp.addSubnet('2002::', 16, 'ipv6'); // 6to4\n\n/**\n * Addresses blocked even when `allowPrivateIp` is on. Cloud metadata services\n * (AWS/GCP/Azure IMDS) live at 169.254.169.254 and would exfiltrate\n * credentials if a CI runner or long-lived server followed an attacker URL to\n * them. IPv6 link-local (`fe80::/10`) is the v6 equivalent reach into the\n * host's local segment.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isAlwaysBlocked(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return alwaysBlocked.check(n.addr, n.family);\n}\n\n/**\n * Reject loopback, link-local, RFC 1918 private ranges, CGNAT (RFC 6598),\n * broadcast, multicast, the unspecified address, NAT64/6to4 wrapper prefixes,\n * and IPv6 equivalents. BlockList handles IPv4-mapped IPv6 canonicalization\n * natively so `::ffff:10.0.0.1` is matched against the v4 rule set.\n *\n * Returns `false` for non-IP inputs (hostnames).\n */\nexport function isPrivateIp(address: string): boolean {\n const n = normalize(address);\n if (!n) return false;\n return privateIp.check(n.addr, n.family);\n}\n\n/**\n * Best-effort check that a URL targets a development/private host, without\n * doing a DNS lookup. Matches loopback hostnames (`localhost`) and any IP\n * literal that {@link isPrivateIp} would reject. Public domain names always\n * return `false`.\n *\n * Used by higher layers that need to inherit the operator's \"private is OK\"\n * trust from a primary probe and propagate it to same-origin chain hops —\n * callers that pass this flag into `ssrfSafeFetch` should do so only when\n * they've already decided the target origin is trusted.\n *\n * Returns `false` on unparseable inputs.\n */\nexport function isLikelyPrivateUrl(url: string): boolean {\n try {\n const u = new URL(url);\n const host = u.hostname.replace(/^\\[|\\]$/g, '').toLowerCase();\n if (host === 'localhost') return true;\n return isPrivateIp(host);\n } catch {\n return false;\n }\n}\n"],"mappings":"AAsBA,SAAS,WAAW,YAAY;AAEhC,SAAS,UAAU,SAAmE;AAEpF,MAAI,OAAO;AACX,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,QAAM,SAAS,KAAK,QAAQ,GAAG;AAC/B,MAAI,UAAU,EAAG,QAAO,KAAK,MAAM,GAAG,MAAM;AAC5C,QAAM,SAAS,KAAK,IAAI;AACxB,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,MAAI,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM,QAAQ,OAAO;AACtD,SAAO;AACT;AAMA,MAAM,gBAAgB,IAAI,UAAU;AACpC,cAAc,UAAU,eAAe,IAAI,MAAM;AACjD,cAAc,UAAU,UAAU,IAAI,MAAM;AAI5C,cAAc,WAAW,eAAe,MAAM;AAM9C,MAAM,YAAY,IAAI,UAAU;AAGhC,UAAU,UAAU,WAAW,GAAG,MAAM;AACxC,UAAU,UAAU,YAAY,GAAG,MAAM;AACzC,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,cAAc,IAAI,MAAM;AAC5C,UAAU,UAAU,gBAAgB,IAAI,MAAM;AAC9C,UAAU,UAAU,eAAe,IAAI,MAAM;AAC7C,UAAU,UAAU,aAAa,GAAG,MAAM;AAC1C,UAAU,WAAW,mBAAmB,MAAM;AAE9C,UAAU,WAAW,MAAM,MAAM;AACjC,UAAU,WAAW,OAAO,MAAM;AAClC,UAAU,UAAU,UAAU,IAAI,MAAM;AACxC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,UAAU,GAAG,MAAM;AACvC,UAAU,UAAU,SAAS,IAAI,MAAM;AACvC,UAAU,UAAU,cAAc,IAAI,MAAM;AAI5C,UAAU,UAAU,aAAa,IAAI,MAAM;AAC3C,UAAU,UAAU,UAAU,IAAI,MAAM;AAWjC,SAAS,gBAAgB,SAA0B;AACxD,QAAM,IAAI,UAAU,OAAO;AAC3B,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,cAAc,MAAM,EAAE,MAAM,EAAE,MAAM;AAC7C;AAUO,SAAS,YAAY,SAA0B;AACpD,QAAM,IAAI,UAAU,OAAO;AAC3B,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,UAAU,MAAM,EAAE,MAAM,EAAE,MAAM;AACzC;AAeO,SAAS,mBAAmB,KAAsB;AACvD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,UAAM,OAAO,EAAE,SAAS,QAAQ,YAAY,EAAE,EAAE,YAAY;AAC5D,QAAI,SAAS,YAAa,QAAO;AACjC,WAAO,YAAY,IAAI;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssrf-fetch.d.ts","sourceRoot":"","sources":["../../../src/lib/net/ssrf-fetch.ts"],"names":[],"mappings":"AAyCA,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,oBAAoB,GACpB,0BAA0B,GAC1B,mBAAmB,GACnB,WAAW,GACX,wBAAwB,GACxB,iBAAiB,GACjB,oBAAoB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,eAAe,CAI5D,CAAC;AAEH;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;gBAEd,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE;CAQ/G;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAC3B,mEAAmE;IACnE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,6DAA6D;IAC7D,IAAI,EAAE,UAAU,CAAC;IACjB,6FAA6F;IAC7F,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,CAAC,GAAG,CAAC,CAAC;IACpB,uFAAuF;IACvF,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,
|
|
1
|
+
{"version":3,"file":"ssrf-fetch.d.ts","sourceRoot":"","sources":["../../../src/lib/net/ssrf-fetch.ts"],"names":[],"mappings":"AAyCA,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,oBAAoB,GACpB,0BAA0B,GAC1B,mBAAmB,GACnB,WAAW,GACX,wBAAwB,GACxB,iBAAiB,GACjB,oBAAoB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,eAAe,CAI5D,CAAC;AAEH;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;gBAEd,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE;CAQ/G;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAC3B,mEAAmE;IACnE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,6DAA6D;IAC7D,IAAI,EAAE,UAAU,CAAC;IACjB,6FAA6F;IAC7F,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,CAAC,GAAG,CAAC,CAAC;IACpB,uFAAuF;IACvF,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA2LzG;AA6BD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAWjG"}
|
|
@@ -74,67 +74,69 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
74
74
|
hostname
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
const ac = new AbortController();
|
|
78
|
+
const onExternalAbort = () => ac.abort(options.signal?.reason);
|
|
79
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
80
|
+
if (options.signal?.aborted) onExternalAbort();
|
|
81
|
+
const timer = setTimeout(() => ac.abort(new Error("ssrf-fetch: timeout")), timeoutMs);
|
|
82
|
+
let dispatcher;
|
|
78
83
|
try {
|
|
79
|
-
addresses
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,
|
|
85
|
-
{ url, hostname }
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
throwIfSignalAborted(options.signal);
|
|
89
|
-
if (addresses.length === 0) {
|
|
90
|
-
throw new SsrfRefusedError("dns_empty", `DNS returned no addresses for ${hostname}`, {
|
|
91
|
-
url,
|
|
92
|
-
hostname
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
for (const a of addresses) {
|
|
96
|
-
if ((0, import_address_guards.isAlwaysBlocked)(a.address)) {
|
|
84
|
+
let addresses;
|
|
85
|
+
try {
|
|
86
|
+
addresses = await raceWithAbort((0, import_promises.lookup)(hostname, { all: true }), ac.signal);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throwIfSignalAborted(ac.signal);
|
|
97
89
|
throw new SsrfRefusedError(
|
|
98
|
-
"
|
|
99
|
-
`
|
|
100
|
-
{ url, hostname
|
|
90
|
+
"dns_lookup_failed",
|
|
91
|
+
`DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
{ url, hostname }
|
|
101
93
|
);
|
|
102
94
|
}
|
|
103
|
-
|
|
104
|
-
|
|
95
|
+
throwIfSignalAborted(ac.signal);
|
|
96
|
+
if (addresses.length === 0) {
|
|
97
|
+
throw new SsrfRefusedError("dns_empty", `DNS returned no addresses for ${hostname}`, {
|
|
98
|
+
url,
|
|
99
|
+
hostname
|
|
100
|
+
});
|
|
101
|
+
}
|
|
105
102
|
for (const a of addresses) {
|
|
106
|
-
if ((0, import_address_guards.
|
|
103
|
+
if ((0, import_address_guards.isAlwaysBlocked)(a.address)) {
|
|
107
104
|
throw new SsrfRefusedError(
|
|
108
|
-
"
|
|
109
|
-
`Refusing to fetch: ${hostname} resolves to
|
|
105
|
+
"always_blocked_address",
|
|
106
|
+
`Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,
|
|
110
107
|
{ url, hostname, address: a.address }
|
|
111
108
|
);
|
|
112
109
|
}
|
|
113
110
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// targets under Node 22+), which expects the array form of the callback.
|
|
123
|
-
lookup: (_h, opts, cb) => {
|
|
124
|
-
if (opts?.all) {
|
|
125
|
-
cb(null, [{ address: pinned.address, family: pinnedFamily }]);
|
|
126
|
-
} else {
|
|
127
|
-
cb(null, pinned.address, pinnedFamily);
|
|
111
|
+
if (!allowPrivateIp) {
|
|
112
|
+
for (const a of addresses) {
|
|
113
|
+
if ((0, import_address_guards.isPrivateIp)(a.address)) {
|
|
114
|
+
throw new SsrfRefusedError(
|
|
115
|
+
"private_address",
|
|
116
|
+
`Refusing to fetch: ${hostname} resolves to a private/loopback address`,
|
|
117
|
+
{ url, hostname, address: a.address }
|
|
118
|
+
);
|
|
128
119
|
}
|
|
129
120
|
}
|
|
130
121
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
122
|
+
const pinned = addresses[0];
|
|
123
|
+
const pinnedFamily = pinned.family === 6 ? 6 : 4;
|
|
124
|
+
dispatcher = new import_undici.Agent({
|
|
125
|
+
connect: {
|
|
126
|
+
timeout: Math.min(5e3, timeoutMs),
|
|
127
|
+
// All addresses were validated above; pin the connect to the first. The
|
|
128
|
+
// custom lookup also means undici won't re-resolve and pick up a rebind.
|
|
129
|
+
// undici's Agent may call lookup with `{ all: true }` (it does for HTTPS
|
|
130
|
+
// targets under Node 22+), which expects the array form of the callback.
|
|
131
|
+
lookup: (_h, opts, cb) => {
|
|
132
|
+
if (opts?.all) {
|
|
133
|
+
cb(null, [{ address: pinned.address, family: pinnedFamily }]);
|
|
134
|
+
} else {
|
|
135
|
+
cb(null, pinned.address, pinnedFamily);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
138
140
|
const res = options.trustedFetchFn ? await options.trustedFetchFn(url, {
|
|
139
141
|
method: options.method ?? "GET",
|
|
140
142
|
redirect: "manual",
|
|
@@ -199,16 +201,27 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
199
201
|
} finally {
|
|
200
202
|
clearTimeout(timer);
|
|
201
203
|
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
202
|
-
await dispatcher
|
|
204
|
+
await dispatcher?.close().catch(() => {
|
|
203
205
|
});
|
|
204
206
|
}
|
|
205
207
|
}
|
|
206
|
-
function
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
function raceWithAbort(operation, signal) {
|
|
209
|
+
throwIfSignalAborted(signal);
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
const onAbort = () => reject(signalAbortError(signal));
|
|
212
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
213
|
+
operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function signalAbortError(signal) {
|
|
217
|
+
if (signal.reason instanceof Error) return signal.reason;
|
|
209
218
|
const error = new Error(signal.reason == null ? "The operation was aborted" : String(signal.reason));
|
|
210
219
|
error.name = "AbortError";
|
|
211
|
-
|
|
220
|
+
return error;
|
|
221
|
+
}
|
|
222
|
+
function throwIfSignalAborted(signal) {
|
|
223
|
+
if (!signal?.aborted) return;
|
|
224
|
+
throw signalAbortError(signal);
|
|
212
225
|
}
|
|
213
226
|
function decodeBodyAsJsonOrText(body, contentType) {
|
|
214
227
|
if (body.byteLength === 0) return null;
|