@adcp/sdk 13.0.0-rc.3 → 13.0.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +18 -15
  2. package/bin/adcp.js +7 -1
  3. package/dist/lib/adapters/property-list-adapter.d.mts +5 -1
  4. package/dist/lib/adapters/property-list-adapter.d.ts +5 -1
  5. package/dist/lib/adapters/property-list-adapter.d.ts.map +1 -1
  6. package/dist/lib/adapters/property-list-adapter.js +7 -7
  7. package/dist/lib/adapters/property-list-adapter.js.map +1 -1
  8. package/dist/lib/adapters/property-list-adapter.mjs +7 -7
  9. package/dist/lib/adapters/property-list-adapter.mjs.map +1 -1
  10. package/dist/lib/auth/oauth/CLIFlowHandler.d.mts +10 -3
  11. package/dist/lib/auth/oauth/CLIFlowHandler.d.ts +10 -3
  12. package/dist/lib/auth/oauth/CLIFlowHandler.d.ts.map +1 -1
  13. package/dist/lib/auth/oauth/CLIFlowHandler.js +40 -4
  14. package/dist/lib/auth/oauth/CLIFlowHandler.js.map +1 -1
  15. package/dist/lib/auth/oauth/CLIFlowHandler.mjs +40 -4
  16. package/dist/lib/auth/oauth/CLIFlowHandler.mjs.map +1 -1
  17. package/dist/lib/auth/oauth/index.d.mts +1 -1
  18. package/dist/lib/auth/oauth/index.d.ts +1 -1
  19. package/dist/lib/auth/oauth/index.d.ts.map +1 -1
  20. package/dist/lib/auth/oauth/index.js +2 -0
  21. package/dist/lib/auth/oauth/index.js.map +1 -1
  22. package/dist/lib/auth/oauth/index.mjs +2 -0
  23. package/dist/lib/auth/oauth/index.mjs.map +1 -1
  24. package/dist/lib/auth/oauth/web-flow.d.mts +12 -0
  25. package/dist/lib/auth/oauth/web-flow.d.ts +12 -0
  26. package/dist/lib/auth/oauth/web-flow.d.ts.map +1 -1
  27. package/dist/lib/auth/oauth/web-flow.js +20 -2
  28. package/dist/lib/auth/oauth/web-flow.js.map +1 -1
  29. package/dist/lib/auth/oauth/web-flow.mjs +19 -2
  30. package/dist/lib/auth/oauth/web-flow.mjs.map +1 -1
  31. package/dist/lib/core/AgentClient.d.ts.map +1 -1
  32. package/dist/lib/core/SingleAgentClient.d.mts +27 -1
  33. package/dist/lib/core/SingleAgentClient.d.ts +27 -1
  34. package/dist/lib/core/SingleAgentClient.d.ts.map +1 -1
  35. package/dist/lib/core/SingleAgentClient.js +46 -18
  36. package/dist/lib/core/SingleAgentClient.js.map +1 -1
  37. package/dist/lib/core/SingleAgentClient.mjs +46 -18
  38. package/dist/lib/core/SingleAgentClient.mjs.map +1 -1
  39. package/dist/lib/index.d.mts +1 -1
  40. package/dist/lib/index.d.ts +1 -1
  41. package/dist/lib/index.d.ts.map +1 -1
  42. package/dist/lib/index.js.map +1 -1
  43. package/dist/lib/index.mjs.map +1 -1
  44. package/dist/lib/net/address-guards.d.ts.map +1 -1
  45. package/dist/lib/net/address-guards.js +4 -0
  46. package/dist/lib/net/address-guards.js.map +1 -1
  47. package/dist/lib/net/address-guards.mjs +4 -0
  48. package/dist/lib/net/address-guards.mjs.map +1 -1
  49. package/dist/lib/protocols/a2a.js +2 -2
  50. package/dist/lib/protocols/a2a.js.map +1 -1
  51. package/dist/lib/protocols/a2a.mjs +2 -2
  52. package/dist/lib/protocols/a2a.mjs.map +1 -1
  53. package/dist/lib/protocols/index.d.ts.map +1 -1
  54. package/dist/lib/protocols/index.js +28 -7
  55. package/dist/lib/protocols/index.js.map +1 -1
  56. package/dist/lib/protocols/index.mjs +28 -7
  57. package/dist/lib/protocols/index.mjs.map +1 -1
  58. package/dist/lib/protocols/mcp-modern.d.ts.map +1 -1
  59. package/dist/lib/protocols/mcp-modern.js +24 -8
  60. package/dist/lib/protocols/mcp-modern.js.map +1 -1
  61. package/dist/lib/protocols/mcp-modern.mjs +24 -8
  62. package/dist/lib/protocols/mcp-modern.mjs.map +1 -1
  63. package/dist/lib/protocols/mcp-tasks.d.ts.map +1 -1
  64. package/dist/lib/protocols/mcp-tasks.js +2 -12
  65. package/dist/lib/protocols/mcp-tasks.js.map +1 -1
  66. package/dist/lib/protocols/mcp-tasks.mjs +2 -12
  67. package/dist/lib/protocols/mcp-tasks.mjs.map +1 -1
  68. package/dist/lib/protocols/mcp.js +2 -2
  69. package/dist/lib/protocols/mcp.js.map +1 -1
  70. package/dist/lib/protocols/mcp.mjs +2 -2
  71. package/dist/lib/protocols/mcp.mjs.map +1 -1
  72. package/dist/lib/registry/index.d.mts +5 -25
  73. package/dist/lib/registry/index.d.ts +5 -25
  74. package/dist/lib/registry/index.d.ts.map +1 -1
  75. package/dist/lib/registry/index.js +5 -130
  76. package/dist/lib/registry/index.js.map +1 -1
  77. package/dist/lib/registry/index.mjs +5 -130
  78. package/dist/lib/registry/index.mjs.map +1 -1
  79. package/dist/lib/registry/sync.d.mts +8 -2
  80. package/dist/lib/registry/sync.d.ts +8 -2
  81. package/dist/lib/registry/sync.d.ts.map +1 -1
  82. package/dist/lib/registry/sync.js +8 -2
  83. package/dist/lib/registry/sync.js.map +1 -1
  84. package/dist/lib/registry/sync.mjs +8 -2
  85. package/dist/lib/registry/sync.mjs.map +1 -1
  86. package/dist/lib/registry/types.d.mts +15 -29
  87. package/dist/lib/registry/types.d.ts +15 -29
  88. package/dist/lib/registry/types.d.ts.map +1 -1
  89. package/dist/lib/registry/types.generated.d.mts +567 -15
  90. package/dist/lib/registry/types.generated.d.ts +567 -15
  91. package/dist/lib/registry/types.generated.d.ts.map +1 -1
  92. package/dist/lib/registry/types.generated.js.map +1 -1
  93. package/dist/lib/registry/types.js.map +1 -1
  94. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  95. package/dist/lib/server/decisioning/account.d.mts +6 -4
  96. package/dist/lib/server/decisioning/account.d.ts +6 -4
  97. package/dist/lib/server/decisioning/account.d.ts.map +1 -1
  98. package/dist/lib/server/decisioning/account.js.map +1 -1
  99. package/dist/lib/server/decisioning/account.mjs.map +1 -1
  100. package/dist/lib/server/decisioning/manifest-helpers.d.mts +6 -5
  101. package/dist/lib/server/decisioning/manifest-helpers.d.ts +6 -5
  102. package/dist/lib/server/decisioning/manifest-helpers.d.ts.map +1 -1
  103. package/dist/lib/server/decisioning/manifest-helpers.js.map +1 -1
  104. package/dist/lib/server/decisioning/manifest-helpers.mjs.map +1 -1
  105. package/dist/lib/server/decisioning/runtime/from-platform.js +4 -2
  106. package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
  107. package/dist/lib/server/decisioning/runtime/from-platform.mjs +4 -2
  108. package/dist/lib/server/decisioning/runtime/from-platform.mjs.map +1 -1
  109. package/dist/lib/server/decisioning/tenant-store.d.mts +26 -14
  110. package/dist/lib/server/decisioning/tenant-store.d.ts +26 -14
  111. package/dist/lib/server/decisioning/tenant-store.d.ts.map +1 -1
  112. package/dist/lib/server/decisioning/tenant-store.js +6 -1
  113. package/dist/lib/server/decisioning/tenant-store.js.map +1 -1
  114. package/dist/lib/server/decisioning/tenant-store.mjs +6 -1
  115. package/dist/lib/server/decisioning/tenant-store.mjs.map +1 -1
  116. package/dist/lib/server/pin-and-bind-fetch.d.mts +2 -0
  117. package/dist/lib/server/pin-and-bind-fetch.d.ts +2 -0
  118. package/dist/lib/server/pin-and-bind-fetch.d.ts.map +1 -1
  119. package/dist/lib/server/pin-and-bind-fetch.js +2 -0
  120. package/dist/lib/server/pin-and-bind-fetch.js.map +1 -1
  121. package/dist/lib/server/pin-and-bind-fetch.mjs +2 -0
  122. package/dist/lib/server/pin-and-bind-fetch.mjs.map +1 -1
  123. package/dist/lib/server/structured-serialize.d.ts.map +1 -1
  124. package/dist/lib/server/structured-serialize.js +10 -2
  125. package/dist/lib/server/structured-serialize.js.map +1 -1
  126. package/dist/lib/server/structured-serialize.mjs +10 -2
  127. package/dist/lib/server/structured-serialize.mjs.map +1 -1
  128. package/dist/lib/server/webhook-emitter.d.ts.map +1 -1
  129. package/dist/lib/server/webhook-emitter.js +4 -2
  130. package/dist/lib/server/webhook-emitter.js.map +1 -1
  131. package/dist/lib/server/webhook-emitter.mjs +4 -2
  132. package/dist/lib/server/webhook-emitter.mjs.map +1 -1
  133. package/dist/lib/signing/middleware.d.mts +2 -1
  134. package/dist/lib/signing/middleware.d.ts +2 -1
  135. package/dist/lib/signing/middleware.d.ts.map +1 -1
  136. package/dist/lib/signing/middleware.js +26 -4
  137. package/dist/lib/signing/middleware.js.map +1 -1
  138. package/dist/lib/signing/middleware.mjs +26 -4
  139. package/dist/lib/signing/middleware.mjs.map +1 -1
  140. package/dist/lib/signing/webhook-verifier.d.ts.map +1 -1
  141. package/dist/lib/signing/webhook-verifier.js +4 -2
  142. package/dist/lib/signing/webhook-verifier.js.map +1 -1
  143. package/dist/lib/signing/webhook-verifier.mjs +4 -2
  144. package/dist/lib/signing/webhook-verifier.mjs.map +1 -1
  145. package/dist/lib/testing/agent-tester.d.ts.map +1 -1
  146. package/dist/lib/testing/agent-tester.js +46 -1
  147. package/dist/lib/testing/agent-tester.js.map +1 -1
  148. package/dist/lib/testing/agent-tester.mjs +46 -1
  149. package/dist/lib/testing/agent-tester.mjs.map +1 -1
  150. package/dist/lib/testing/storyboard/runner.d.ts.map +1 -1
  151. package/dist/lib/testing/storyboard/runner.js +40 -6
  152. package/dist/lib/testing/storyboard/runner.js.map +1 -1
  153. package/dist/lib/testing/storyboard/runner.mjs +40 -6
  154. package/dist/lib/testing/storyboard/runner.mjs.map +1 -1
  155. package/dist/lib/types/asset-instances.d.mts +8 -6
  156. package/dist/lib/types/asset-instances.d.ts +8 -6
  157. package/dist/lib/types/asset-instances.d.ts.map +1 -1
  158. package/dist/lib/types/asset-instances.js.map +1 -1
  159. package/dist/lib/types/core.generated.d.mts +105 -58
  160. package/dist/lib/types/core.generated.d.ts +105 -58
  161. package/dist/lib/types/core.generated.d.ts.map +1 -1
  162. package/dist/lib/types/core.generated.js.map +1 -1
  163. package/dist/lib/types/index.d.ts.map +1 -1
  164. package/dist/lib/types/index.js.map +1 -1
  165. package/dist/lib/types/index.mjs.map +1 -1
  166. package/dist/lib/types/schemas.generated.d.mts +2352 -473
  167. package/dist/lib/types/schemas.generated.d.ts +2352 -473
  168. package/dist/lib/types/schemas.generated.d.ts.map +1 -1
  169. package/dist/lib/types/schemas.generated.js +78 -45
  170. package/dist/lib/types/schemas.generated.js.map +1 -1
  171. package/dist/lib/types/schemas.generated.mjs +71 -45
  172. package/dist/lib/types/schemas.generated.mjs.map +1 -1
  173. package/dist/lib/types/tools.generated.d.mts +48 -44
  174. package/dist/lib/types/tools.generated.d.ts +48 -44
  175. package/dist/lib/types/tools.generated.d.ts.map +1 -1
  176. package/dist/lib/types/tools.generated.js.map +1 -1
  177. package/dist/lib/utils/probe-policy.d.mts +0 -16
  178. package/dist/lib/utils/probe-policy.d.ts +0 -16
  179. package/dist/lib/utils/probe-policy.d.ts.map +1 -1
  180. package/dist/lib/utils/probe-policy.js +15 -1
  181. package/dist/lib/utils/probe-policy.js.map +1 -1
  182. package/dist/lib/utils/probe-policy.mjs +15 -1
  183. package/dist/lib/utils/probe-policy.mjs.map +1 -1
  184. package/dist/lib/utils/redact-args.d.mts +36 -0
  185. package/dist/lib/utils/redact-args.d.ts +37 -0
  186. package/dist/lib/utils/redact-args.d.ts.map +1 -0
  187. package/dist/lib/utils/redact-args.js +46 -0
  188. package/dist/lib/utils/redact-args.js.map +1 -0
  189. package/dist/lib/utils/redact-args.mjs +22 -0
  190. package/dist/lib/utils/redact-args.mjs.map +1 -0
  191. package/dist/lib/v2/projection/creative-delivery.js +1 -5
  192. package/dist/lib/v2/projection/creative-delivery.js.map +1 -1
  193. package/dist/lib/v2/projection/creative-delivery.mjs +1 -5
  194. package/dist/lib/v2/projection/creative-delivery.mjs.map +1 -1
  195. package/dist/lib/validation/index.d.mts +15 -1
  196. package/dist/lib/validation/index.d.ts +15 -1
  197. package/dist/lib/validation/index.d.ts.map +1 -1
  198. package/dist/lib/validation/index.js +4 -8
  199. package/dist/lib/validation/index.js.map +1 -1
  200. package/dist/lib/validation/index.mjs +4 -8
  201. package/dist/lib/validation/index.mjs.map +1 -1
  202. package/dist/lib/version.d.mts +3 -3
  203. package/dist/lib/version.d.ts +3 -3
  204. package/dist/lib/version.js +3 -3
  205. package/dist/lib/version.js.map +1 -1
  206. package/dist/lib/version.mjs +3 -3
  207. package/dist/lib/version.mjs.map +1 -1
  208. package/examples/hello_seller_adapter_multi_tenant.ts +7 -0
  209. package/package.json +6 -5
  210. package/skills/build-holdco-agent/SKILL.md +19 -0
  211. package/skills/cross-cutting.md +3 -1
@@ -6,12 +6,16 @@
6
6
  * account-sync tools (`sync_accounts` / `sync_governance`) that adopters
7
7
  * historically had to write — and silently fail to write — by hand.
8
8
  *
9
- * NOTE: `accounts.resolve` is NOT gated by default (`refAccess: 'ref-routed'`)
10
- * — it returns whatever tenant the buyer's ref points at, which is correct
11
- * for the agency-hub model where one credential spans tenants. Deployments
12
- * where a credential must NOT reach another tenant's account set
13
- * `refAccess: 'auth-scoped'` (gates `resolve` fail-closed) or compose a
14
- * `resolve-presets` guard. See {@link TenantStoreConfig.refAccess}.
9
+ * NOTE: `refAccess` is REQUIRED and has no default, because the safe value
10
+ * depends on a fact only the adopter knows. `'ref-routed'` returns whatever
11
+ * tenant the buyer's ref points at — correct for the agency-hub model where one
12
+ * credential legitimately spans tenants, and a cross-tenant spend hole where it
13
+ * doesn't. `'auth-scoped'` gates `resolve` fail-closed.
14
+ *
15
+ * `refAccess` governs `resolve` ONLY. `upsert` / `syncGovernance` enforce the
16
+ * tenant gate either way — so an adopter who has verified those is NOT covered
17
+ * on `resolve`, and `resolve` is the account path for `create_media_buy` and
18
+ * `update_media_buy`. See {@link TenantStoreConfig.refAccess}.
15
19
  *
16
20
  * Full walkthrough with same-tenant invariant + production caveats:
17
21
  * `skills/build-holdco-agent/SKILL.md`. Worked example:
@@ -50,7 +54,15 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
50
54
  * Controls whether a buyer-supplied account ref on `accounts.resolve` is
51
55
  * gated against the authenticated principal's tenant.
52
56
  *
53
- * - `'ref-routed'` (default) — `resolve` returns whatever tenant the ref
57
+ * REQUIRED, deliberately with no default. Both values are correct for some
58
+ * deployments and catastrophic for others, and nothing in the code can tell
59
+ * which one you are: `'ref-routed'` is right for an agency hub whose single
60
+ * credential legitimately spans tenants, and a cross-tenant spend hole for a
61
+ * hub whose tenants are unrelated clients. A default would silently pick one
62
+ * of those for you. Making it required turns that into a `tsc` error you
63
+ * resolve once, at construction, instead of a runtime surprise.
64
+ *
65
+ * - `'ref-routed'` — `resolve` returns whatever tenant the ref
54
66
  * points at, WITHOUT checking the caller. This is correct for the
55
67
  * agency-hub / account-routed model where one credential legitimately
56
68
  * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).
@@ -68,15 +80,15 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
68
80
  * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always
69
81
  * enforce the tenant gate regardless of this setting.
70
82
  */
71
- refAccess?: 'ref-routed' | 'auth-scoped';
83
+ refAccess: 'ref-routed' | 'auth-scoped';
72
84
  /**
73
85
  * Path 1: account ref carries `account_id` OR `(brand, operator)`.
74
86
  * Resolve to the tenant the ref points at. Return null if the ref is
75
87
  * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).
76
88
  *
77
- * By default (`refAccess: 'ref-routed'`) this resolves independent of who
78
- * the caller is. Set `refAccess: 'auth-scoped'` to make `resolve` reject a
79
- * ref that points at a tenant other than the caller's.
89
+ * Under `refAccess: 'ref-routed'` this resolves independent of who the caller
90
+ * is. Under `refAccess: 'auth-scoped'` a ref pointing at a tenant other than
91
+ * the caller's is rejected.
80
92
  *
81
93
  * Receives the full `AccountReference` so adopters can route on
82
94
  * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the
@@ -150,9 +162,9 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
150
162
  * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.
151
163
  * Returns `null` if the resolver returned `null` (framework emits
152
164
  * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats
153
- * absence as "no tenant" for tools that don't). By default this path does
154
- * NOT check the caller against the ref's tenant — set
155
- * `refAccess: 'auth-scoped'` to fail closed on a cross-tenant ref.
165
+ * absence as "no tenant" for tools that don't). Under
166
+ * `refAccess: 'ref-routed'` this path does NOT check the caller against the
167
+ * ref's tenant; `refAccess: 'auth-scoped'` fails closed on a cross-tenant ref.
156
168
  *
157
169
  * - `accounts.upsert(refs, ctx)` — for each ref:
158
170
  * 1. Resolve the entry's tenant via `resolveByRef`.
@@ -6,12 +6,16 @@
6
6
  * account-sync tools (`sync_accounts` / `sync_governance`) that adopters
7
7
  * historically had to write — and silently fail to write — by hand.
8
8
  *
9
- * NOTE: `accounts.resolve` is NOT gated by default (`refAccess: 'ref-routed'`)
10
- * — it returns whatever tenant the buyer's ref points at, which is correct
11
- * for the agency-hub model where one credential spans tenants. Deployments
12
- * where a credential must NOT reach another tenant's account set
13
- * `refAccess: 'auth-scoped'` (gates `resolve` fail-closed) or compose a
14
- * `resolve-presets` guard. See {@link TenantStoreConfig.refAccess}.
9
+ * NOTE: `refAccess` is REQUIRED and has no default, because the safe value
10
+ * depends on a fact only the adopter knows. `'ref-routed'` returns whatever
11
+ * tenant the buyer's ref points at — correct for the agency-hub model where one
12
+ * credential legitimately spans tenants, and a cross-tenant spend hole where it
13
+ * doesn't. `'auth-scoped'` gates `resolve` fail-closed.
14
+ *
15
+ * `refAccess` governs `resolve` ONLY. `upsert` / `syncGovernance` enforce the
16
+ * tenant gate either way — so an adopter who has verified those is NOT covered
17
+ * on `resolve`, and `resolve` is the account path for `create_media_buy` and
18
+ * `update_media_buy`. See {@link TenantStoreConfig.refAccess}.
15
19
  *
16
20
  * Full walkthrough with same-tenant invariant + production caveats:
17
21
  * `skills/build-holdco-agent/SKILL.md`. Worked example:
@@ -50,7 +54,15 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
50
54
  * Controls whether a buyer-supplied account ref on `accounts.resolve` is
51
55
  * gated against the authenticated principal's tenant.
52
56
  *
53
- * - `'ref-routed'` (default) — `resolve` returns whatever tenant the ref
57
+ * REQUIRED, deliberately with no default. Both values are correct for some
58
+ * deployments and catastrophic for others, and nothing in the code can tell
59
+ * which one you are: `'ref-routed'` is right for an agency hub whose single
60
+ * credential legitimately spans tenants, and a cross-tenant spend hole for a
61
+ * hub whose tenants are unrelated clients. A default would silently pick one
62
+ * of those for you. Making it required turns that into a `tsc` error you
63
+ * resolve once, at construction, instead of a runtime surprise.
64
+ *
65
+ * - `'ref-routed'` — `resolve` returns whatever tenant the ref
54
66
  * points at, WITHOUT checking the caller. This is correct for the
55
67
  * agency-hub / account-routed model where one credential legitimately
56
68
  * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).
@@ -68,15 +80,15 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
68
80
  * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always
69
81
  * enforce the tenant gate regardless of this setting.
70
82
  */
71
- refAccess?: 'ref-routed' | 'auth-scoped';
83
+ refAccess: 'ref-routed' | 'auth-scoped';
72
84
  /**
73
85
  * Path 1: account ref carries `account_id` OR `(brand, operator)`.
74
86
  * Resolve to the tenant the ref points at. Return null if the ref is
75
87
  * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).
76
88
  *
77
- * By default (`refAccess: 'ref-routed'`) this resolves independent of who
78
- * the caller is. Set `refAccess: 'auth-scoped'` to make `resolve` reject a
79
- * ref that points at a tenant other than the caller's.
89
+ * Under `refAccess: 'ref-routed'` this resolves independent of who the caller
90
+ * is. Under `refAccess: 'auth-scoped'` a ref pointing at a tenant other than
91
+ * the caller's is rejected.
80
92
  *
81
93
  * Receives the full `AccountReference` so adopters can route on
82
94
  * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the
@@ -150,9 +162,9 @@ export interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>>
150
162
  * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.
151
163
  * Returns `null` if the resolver returned `null` (framework emits
152
164
  * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats
153
- * absence as "no tenant" for tools that don't). By default this path does
154
- * NOT check the caller against the ref's tenant — set
155
- * `refAccess: 'auth-scoped'` to fail closed on a cross-tenant ref.
165
+ * absence as "no tenant" for tools that don't). Under
166
+ * `refAccess: 'ref-routed'` this path does NOT check the caller against the
167
+ * ref's tenant; `refAccess: 'auth-scoped'` fails closed on a cross-tenant ref.
156
168
  *
157
169
  * - `accounts.upsert(refs, ctx)` — for each ref:
158
170
  * 1. Resolve the entry's tenant via `resolveByRef`.
@@ -1 +1 @@
1
- {"version":3,"file":"tenant-store.d.ts","sourceRoot":"","sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAElH,KAAK,mBAAmB,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AACrE,KAAK,iBAAiB,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC5E;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,SAAS,CAAC,EAAE,YAAY,GAAG,aAAa,CAAC;IAEzC;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IAE9E;;;;;;;;;;OAUG;IACH,eAAe,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IAE/E;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC;IAElC;;;;;;;;;;OAUG;IACH,eAAe,CACb,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,gBAAgB,GAAG,SAAS,EACjC,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElD;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,CACR,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,gBAAgB,EACrB,GAAG,EAAE,cAAc,GAClB,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAE1D;;;;;;;;OAQG;IACH,iBAAiB,CAAC,CAChB,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,mBAAmB,EAC1B,GAAG,EAAE,cAAc,GAClB,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3E,MAAM,EAAE,iBAAiB,CAAC,OAAO,EAAE,QAAQ,CAAC,GAC3C,YAAY,CAAC,QAAQ,CAAC,CAuFxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AACF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,SAAS,CAAC;AAC5D,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,SAAS,GAC9D;IACE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GACD,SAAS,CAAC"}
1
+ {"version":3,"file":"tenant-store.d.ts","sourceRoot":"","sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAElH,KAAK,mBAAmB,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AACrE,KAAK,iBAAiB,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,SAAS,EAAE,YAAY,GAAG,aAAa,CAAC;IAExC;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IAE9E;;;;;;;;;;OAUG;IACH,eAAe,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IAE/E;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC;IAElC;;;;;;;;;;OAUG;IACH,eAAe,CACb,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,gBAAgB,GAAG,SAAS,EACjC,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElD;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,CACR,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,gBAAgB,EACrB,GAAG,EAAE,cAAc,GAClB,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAE1D;;;;;;;;OAQG;IACH,iBAAiB,CAAC,CAChB,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,mBAAmB,EAC1B,GAAG,EAAE,cAAc,GAClB,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3E,MAAM,EAAE,iBAAiB,CAAC,OAAO,EAAE,QAAQ,CAAC,GAC3C,YAAY,CAAC,QAAQ,CAAC,CAsGxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AACF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,SAAS,CAAC;AAC5D,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,SAAS,GAC9D;IACE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GACD,SAAS,CAAC"}
@@ -23,6 +23,11 @@ __export(tenant_store_exports, {
23
23
  });
24
24
  module.exports = __toCommonJS(tenant_store_exports);
25
25
  function createTenantStore(config) {
26
+ if (config.refAccess !== "ref-routed" && config.refAccess !== "auth-scoped") {
27
+ throw new Error(
28
+ 'createTenantStore: `refAccess` is required and must be "ref-routed" or "auth-scoped". There is deliberately no default \u2014 the safe value depends on whether one credential is supposed to span tenants. Use "auth-scoped" when a credential must NOT reach another tenant\'s account by naming its ref (most multi-tenant deployments), or "ref-routed" for an agency hub whose single credential legitimately spans tenants. Note this governs `resolve` only \u2014 which is the account path for create_media_buy and update_media_buy \u2014 while `upsert` / `syncGovernance` are gated either way.'
29
+ );
30
+ }
26
31
  const store = {
27
32
  resolve: async (ref, ctx) => {
28
33
  const resolveCtx = ctx ?? {};
@@ -33,7 +38,7 @@ function createTenantStore(config) {
33
38
  }
34
39
  const entryTenant = await config.resolveByRef(ref);
35
40
  if (entryTenant == null) return null;
36
- if ((config.refAccess ?? "ref-routed") === "auth-scoped") {
41
+ if (config.refAccess === "auth-scoped") {
37
42
  const authTenant = await config.resolveFromAuth(resolveCtx);
38
43
  if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {
39
44
  return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"sourcesContent":["/**\n * `createTenantStore` — opinionated `AccountStore` builder for multi-tenant\n * adapters. Canonicalizes the two-path resolution shape (operator-routed\n * for tools that carry `account` on the wire; auth-derived for tools that\n * don't) and bakes in the per-entry tenant-isolation gate on the mutating\n * account-sync tools (`sync_accounts` / `sync_governance`) that adopters\n * historically had to write — and silently fail to write — by hand.\n *\n * NOTE: `accounts.resolve` is NOT gated by default (`refAccess: 'ref-routed'`)\n * — it returns whatever tenant the buyer's ref points at, which is correct\n * for the agency-hub model where one credential spans tenants. Deployments\n * where a credential must NOT reach another tenant's account set\n * `refAccess: 'auth-scoped'` (gates `resolve` fail-closed) or compose a\n * `resolve-presets` guard. See {@link TenantStoreConfig.refAccess}.\n *\n * Full walkthrough with same-tenant invariant + production caveats:\n * `skills/build-holdco-agent/SKILL.md`. Worked example:\n * `examples/hello_seller_adapter_multi_tenant.ts`.\n *\n * Status: Preview / 6.x.\n *\n * @public\n */\n\nimport type { ResolveContext, Account, AccountStore, SyncAccountsResultRow } from './account';\nimport type { AccountReference, SyncGovernanceRequest, SyncGovernanceSuccess } from '../../types/tools.generated';\n\ntype SyncGovernanceEntry = SyncGovernanceRequest['accounts'][number];\ntype SyncGovernanceRow = SyncGovernanceSuccess['accounts'][number];\n\n/**\n * Adopter contract for `createTenantStore`. Every callback is sync OR async;\n * the helper awaits.\n *\n * Tenant isolation is enforced by comparing `tenantId(authTenant)` against\n * `tenantId(entryTenant)` per-entry on `sync_accounts` / `sync_governance`.\n * Mismatches produce a `'failed'` row with `code: 'PERMISSION_DENIED'` —\n * the adopter's `upsertRow` / `syncGovernanceRow` callbacks NEVER see a\n * cross-tenant entry. Fail-closed when `resolveFromAuth` returns null\n * (unknown principal): every entry fails `PERMISSION_DENIED` regardless of\n * its operator. Don't fork this around to fail-open — adopters who copied\n * the prior fail-open shape (`if (homeTenantId && tenantId !== homeTenantId)`)\n * silently disabled isolation when a credential lacked a tenant binding.\n *\n * @template TTenant Adopter's tenant model (e.g., `TenantState`, a row\n * from a `tenants` table). Compared via `tenantId`,\n * not by reference.\n * @template TCtxMeta Shape of `Account.ctx_metadata`. Threads through to\n * every specialism handler via `ctx.account.ctx_metadata`.\n */\nexport interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>> {\n /**\n * Controls whether a buyer-supplied account ref on `accounts.resolve` is\n * gated against the authenticated principal's tenant.\n *\n * - `'ref-routed'` (default) — `resolve` returns whatever tenant the ref\n * points at, WITHOUT checking the caller. This is correct for the\n * agency-hub / account-routed model where one credential legitimately\n * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).\n * In this mode `resolve` performs NO isolation check — any authenticated\n * caller can resolve any tenant's account by naming its `account_id` /\n * `operator`. Isolation for such deployments must be layered on top with\n * a `resolve-presets` guard (`requireAccountMatch` /\n * `requireAdvertiserMatch` / `requireOrgScope`) via `composeMethod`.\n * - `'auth-scoped'` — `resolve` fails closed: a ref that resolves to a\n * tenant other than `resolveFromAuth(ctx)` (or an unresolvable auth\n * principal) returns `null` (framework emits `ACCOUNT_NOT_FOUND`). Use\n * this when a credential must NOT be able to reach another tenant's\n * account by supplying its ref.\n *\n * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always\n * enforce the tenant gate regardless of this setting.\n */\n refAccess?: 'ref-routed' | 'auth-scoped';\n\n /**\n * Path 1: account ref carries `account_id` OR `(brand, operator)`.\n * Resolve to the tenant the ref points at. Return null if the ref is\n * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).\n *\n * By default (`refAccess: 'ref-routed'`) this resolves independent of who\n * the caller is. Set `refAccess: 'auth-scoped'` to make `resolve` reject a\n * ref that points at a tenant other than the caller's.\n *\n * Receives the full `AccountReference` so adopters can route on\n * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the\n * `account_id` arm of the discriminated union.\n */\n resolveByRef(ref: AccountReference): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Path 2: no account ref on the wire (`get_brand_identity`, `get_rights`,\n * `provide_performance_feedback`, `list_creative_formats`). Derive the\n * tenant from the auth principal. Return null if no principal is\n * resolvable (no auth, no `agentRegistry`, principal not registered).\n *\n * Used by both `accounts.resolve(undefined, ctx)` (no-account tools)\n * AND the tenant-isolation gate on per-entry tools — `null` here\n * means EVERY entry on `sync_accounts` / `sync_governance` fails\n * `PERMISSION_DENIED` (fail-closed).\n */\n resolveFromAuth(ctx: ResolveContext): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Stable identity for tenant-equality checks. The helper compares\n * `tenantId(authTenant) === tenantId(entryTenant)` to enforce isolation.\n * Reference equality is fragile (Postgres-backed stores hand back fresh\n * objects each fetch); a stable string id closes that gap.\n */\n tenantId(tenant: TTenant): string;\n\n /**\n * Project `(tenant, ref)` to the framework `Account<TCtxMeta>`. Called by\n * `accounts.resolve` after tenant resolution. Adopters thread sandbox\n * routing here (`sandbox: ref?.sandbox`), pin `ctx_metadata` for\n * downstream handlers, and shape `name` / `operator` / `brand` to match\n * the wire echo conventions their buyers expect.\n *\n * `ref` is `undefined` on Path-2 (no-account tools) — adopters returning\n * a synthetic publisher-wide singleton omit `ref?.sandbox` (no sandbox\n * boundary applies).\n */\n tenantToAccount(\n tenant: TTenant,\n ref: AccountReference | undefined,\n ctx: ResolveContext\n ): Account<TCtxMeta> | Promise<Account<TCtxMeta>>;\n\n /**\n * Per-entry `sync_accounts` storage callback. Called for each entry whose\n * `(authTenant === entryTenant)` check passed. Receives the resolved\n * tenant + the original ref + ctx. Returns a `SyncAccountsResultRow`.\n *\n * Cross-tenant entries and unknown-ref entries never reach this callback\n * — the helper builds `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` rows for\n * those before invoking your code. The adopter's only job is the actual\n * upsert.\n *\n * Optional. Omit if your platform doesn't claim `sync_accounts`; the\n * helper leaves `accounts.upsert` undefined and the framework returns\n * `UNSUPPORTED_FEATURE`.\n */\n upsertRow?(\n tenant: TTenant,\n ref: AccountReference,\n ctx: ResolveContext\n ): SyncAccountsResultRow | Promise<SyncAccountsResultRow>;\n\n /**\n * Per-entry `sync_governance` storage callback. Same gating rules as\n * `upsertRow` — cross-tenant entries are rejected before reaching this\n * code. Adopters persist the buyer's governance-agent binding (including\n * write-only `authentication.credentials`, which the framework strips\n * from the response automatically — see `toWireSyncGovernanceRow`).\n *\n * Optional. Omit if your platform doesn't claim `sync_governance`.\n */\n syncGovernanceRow?(\n tenant: TTenant,\n entry: SyncGovernanceEntry,\n ctx: ResolveContext\n ): SyncGovernanceRow | Promise<SyncGovernanceRow>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` whose `resolve` / `upsert` /\n * `syncGovernance` methods enforce tenant isolation.\n *\n * The helper produces:\n *\n * - `accounts.resolve(ref, ctx)` — calls `resolveByRef(ref)` when `ref` is\n * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.\n * Returns `null` if the resolver returned `null` (framework emits\n * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats\n * absence as \"no tenant\" for tools that don't). By default this path does\n * NOT check the caller against the ref's tenant — set\n * `refAccess: 'auth-scoped'` to fail closed on a cross-tenant ref.\n *\n * - `accounts.upsert(refs, ctx)` — for each ref:\n * 1. Resolve the entry's tenant via `resolveByRef`.\n * 2. Resolve the auth principal's tenant via `resolveFromAuth(ctx)`\n * (computed once per request).\n * 3. If the entry tenant is unknown, emit `ACCOUNT_NOT_FOUND`.\n * 4. If the auth tenant is unknown OR differs from the entry tenant,\n * emit `PERMISSION_DENIED` (fail-closed).\n * 5. Otherwise, invoke the adopter's `upsertRow`.\n *\n * - `accounts.syncGovernance(entries, ctx)` — same gating as `upsert`,\n * shaped for the `SyncGovernanceResponseRow` arm (`status: 'failed'`\n * with per-entry `errors`).\n *\n * `accounts.list` and `accounts.reportUsage` / `accounts.getAccountFinancials`\n * are NOT generated by this helper — those tools have shapes (cursor\n * pagination; per-row account refs spanning multiple tenants in\n * `report_usage`) that don't fit the per-entry-then-row pattern. Adopters\n * who claim those capabilities extend the returned store with\n * `Object.assign`:\n *\n * ```ts\n * const accounts = Object.assign(\n * createTenantStore<TenantState, TenantMeta>({...}),\n * { list: async (filter, ctx) => { ... } }\n * );\n * ```\n *\n * **Direct mutation of `upsert` / `syncGovernance` is locked.** The helper\n * makes those properties non-writable (`Object.defineProperty`) so an\n * adopter who writes `accounts.upsert = customHandler` after construction\n * gets a TypeError instead of silently bypassing the tenant gate. If you\n * really need a different `upsert`, don't use the helper — write a plain\n * `AccountStore` and own the gate.\n */\nexport function createTenantStore<TTenant, TCtxMeta = Record<string, unknown>>(\n config: TenantStoreConfig<TTenant, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolve: async (ref, ctx) => {\n const resolveCtx = ctx ?? {};\n if (!ref) {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n if (authTenant == null) return null;\n return await config.tenantToAccount(authTenant, ref, resolveCtx);\n }\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) return null;\n if ((config.refAccess ?? 'ref-routed') === 'auth-scoped') {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n // Fail-closed: an unresolvable principal OR a ref pointing at a\n // different tenant is treated as not found — a caller cannot reach\n // another tenant's account by naming its ref.\n if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {\n return null;\n }\n }\n return await config.tenantToAccount(entryTenant, ref, resolveCtx);\n },\n };\n\n if (config.upsertRow) {\n const upsertRow = config.upsertRow;\n const upsert: NonNullable<AccountStore<TCtxMeta>['upsert']> = async (refs, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n // Sequential, not Promise.all: adopter `upsertRow` callbacks\n // commonly mutate shared tenant state (the multi-tenant adapter's\n // `tenant.accounts.set(...)` is the canonical example). Concurrent\n // invocations against the same tenant are an entropy source the\n // helper shouldn't introduce. Adopters who want parallel writes\n // can fan out inside their callback against an upstream that\n // tolerates it.\n const rows: SyncAccountsResultRow[] = [];\n for (const ref of refs) {\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) {\n rows.push(buildSyncAccountsFailedRow(ref, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(ref)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncAccountsFailedRow(ref, 'PERMISSION_DENIED', permissionDeniedMessage(ref)));\n continue;\n }\n rows.push(await upsertRow(entryTenant, ref, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'upsert', { value: upsert, writable: false, configurable: false, enumerable: true });\n }\n\n if (config.syncGovernanceRow) {\n const syncGovernanceRow = config.syncGovernanceRow;\n const syncGovernance: NonNullable<AccountStore<TCtxMeta>['syncGovernance']> = async (entries, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n const rows: SyncGovernanceRow[] = [];\n for (const entry of entries) {\n const entryTenant = await config.resolveByRef(entry.account);\n if (entryTenant == null) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(entry.account)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'PERMISSION_DENIED', permissionDeniedMessage(entry.account)));\n continue;\n }\n rows.push(await syncGovernanceRow(entryTenant, entry, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'syncGovernance', {\n value: syncGovernance,\n writable: false,\n configurable: false,\n enumerable: true,\n });\n }\n\n return store;\n}\n\n/**\n * Read fields from an `AccountReference` without per-arm narrowing. The wire\n * type is a discriminated union (`{account_id} | {brand, operator}`) — schema\n * validation upstream guarantees one arm is populated, so widening to an\n * all-optional record is safe and saves a `'in' ref ? ref.x : fallback` dance\n * inside `tenantToAccount` / `resolveByRef` / failed-row builders.\n *\n * Use inside adopter `createTenantStore({...})` callbacks to read\n * `ref.operator`, `ref.brand?.domain`, or `ref.account_id` without inline\n * casts. This is the same helper the framework uses internally for its\n * `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` row construction.\n *\n * ```ts\n * tenantToAccount: (tenant, ref, ctx) => {\n * const r = narrowAccountRef(ref);\n * return {\n * id: tenant.id,\n * operator: r?.operator ?? ctx?.agent?.agent_url ?? 'derived',\n * ...(r?.brand?.domain && { brand: { domain: r.brand.domain } }),\n *\n * };\n * }\n * ```\n *\n * Returns `undefined` on `undefined` input — the no-account-tool path.\n */\nexport function narrowAccountRef(ref: AccountReference): {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n};\nexport function narrowAccountRef(ref: undefined): undefined;\nexport function narrowAccountRef(ref: AccountReference | undefined):\n | {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n }\n | undefined;\nexport function narrowAccountRef(\n ref: AccountReference | undefined\n): { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean } | undefined {\n if (ref === undefined) return undefined;\n return ref as { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean };\n}\n\n/**\n * Build a failed `sync_accounts` row when the helper rejects an entry\n * (cross-tenant or unknown ref). The wire schema requires `brand` +\n * `operator` on every row, so when the input ref is `account_id`-only\n * we synthesize `'unknown'` placeholders — the buyer's `errors[0].code`\n * is the actionable signal; `brand` / `operator` here are wire-required\n * scaffolding, not authoritative echoes.\n */\nfunction buildSyncAccountsFailedRow(\n ref: AccountReference,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncAccountsResultRow {\n const r = narrowAccountRef(ref);\n return {\n brand: { domain: r.brand?.domain ?? 'unknown.example' },\n operator: r.operator ?? 'unknown',\n action: 'failed',\n status: 'rejected',\n errors: [{ code, message }],\n ...(r.account_id != null && { account_id: r.account_id }),\n };\n}\n\nfunction buildSyncGovernanceFailedRow(\n entry: SyncGovernanceEntry,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncGovernanceRow {\n return {\n account: entry.account,\n status: 'failed',\n errors: [{ code, message }],\n };\n}\n\nfunction accountNotFoundMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n if (r.account_id) return `Unknown account_id: ${r.account_id}`;\n if (r.operator) return `Unknown operator: ${r.operator}`;\n return 'Unknown account reference';\n}\n\nfunction permissionDeniedMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n const subject = r.operator ?? r.account_id ?? 'this account';\n return `Buyer agent has no authority over '${subject}' (tenant mismatch or auth principal not registered).`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"sourcesContent":["/**\n * `createTenantStore` — opinionated `AccountStore` builder for multi-tenant\n * adapters. Canonicalizes the two-path resolution shape (operator-routed\n * for tools that carry `account` on the wire; auth-derived for tools that\n * don't) and bakes in the per-entry tenant-isolation gate on the mutating\n * account-sync tools (`sync_accounts` / `sync_governance`) that adopters\n * historically had to write — and silently fail to write — by hand.\n *\n * NOTE: `refAccess` is REQUIRED and has no default, because the safe value\n * depends on a fact only the adopter knows. `'ref-routed'` returns whatever\n * tenant the buyer's ref points at — correct for the agency-hub model where one\n * credential legitimately spans tenants, and a cross-tenant spend hole where it\n * doesn't. `'auth-scoped'` gates `resolve` fail-closed.\n *\n * `refAccess` governs `resolve` ONLY. `upsert` / `syncGovernance` enforce the\n * tenant gate either way — so an adopter who has verified those is NOT covered\n * on `resolve`, and `resolve` is the account path for `create_media_buy` and\n * `update_media_buy`. See {@link TenantStoreConfig.refAccess}.\n *\n * Full walkthrough with same-tenant invariant + production caveats:\n * `skills/build-holdco-agent/SKILL.md`. Worked example:\n * `examples/hello_seller_adapter_multi_tenant.ts`.\n *\n * Status: Preview / 6.x.\n *\n * @public\n */\n\nimport type { ResolveContext, Account, AccountStore, SyncAccountsResultRow } from './account';\nimport type { AccountReference, SyncGovernanceRequest, SyncGovernanceSuccess } from '../../types/tools.generated';\n\ntype SyncGovernanceEntry = SyncGovernanceRequest['accounts'][number];\ntype SyncGovernanceRow = SyncGovernanceSuccess['accounts'][number];\n\n/**\n * Adopter contract for `createTenantStore`. Every callback is sync OR async;\n * the helper awaits.\n *\n * Tenant isolation is enforced by comparing `tenantId(authTenant)` against\n * `tenantId(entryTenant)` per-entry on `sync_accounts` / `sync_governance`.\n * Mismatches produce a `'failed'` row with `code: 'PERMISSION_DENIED'` —\n * the adopter's `upsertRow` / `syncGovernanceRow` callbacks NEVER see a\n * cross-tenant entry. Fail-closed when `resolveFromAuth` returns null\n * (unknown principal): every entry fails `PERMISSION_DENIED` regardless of\n * its operator. Don't fork this around to fail-open — adopters who copied\n * the prior fail-open shape (`if (homeTenantId && tenantId !== homeTenantId)`)\n * silently disabled isolation when a credential lacked a tenant binding.\n *\n * @template TTenant Adopter's tenant model (e.g., `TenantState`, a row\n * from a `tenants` table). Compared via `tenantId`,\n * not by reference.\n * @template TCtxMeta Shape of `Account.ctx_metadata`. Threads through to\n * every specialism handler via `ctx.account.ctx_metadata`.\n */\nexport interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>> {\n /**\n * Controls whether a buyer-supplied account ref on `accounts.resolve` is\n * gated against the authenticated principal's tenant.\n *\n * REQUIRED, deliberately with no default. Both values are correct for some\n * deployments and catastrophic for others, and nothing in the code can tell\n * which one you are: `'ref-routed'` is right for an agency hub whose single\n * credential legitimately spans tenants, and a cross-tenant spend hole for a\n * hub whose tenants are unrelated clients. A default would silently pick one\n * of those for you. Making it required turns that into a `tsc` error you\n * resolve once, at construction, instead of a runtime surprise.\n *\n * - `'ref-routed'` — `resolve` returns whatever tenant the ref\n * points at, WITHOUT checking the caller. This is correct for the\n * agency-hub / account-routed model where one credential legitimately\n * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).\n * In this mode `resolve` performs NO isolation check — any authenticated\n * caller can resolve any tenant's account by naming its `account_id` /\n * `operator`. Isolation for such deployments must be layered on top with\n * a `resolve-presets` guard (`requireAccountMatch` /\n * `requireAdvertiserMatch` / `requireOrgScope`) via `composeMethod`.\n * - `'auth-scoped'` — `resolve` fails closed: a ref that resolves to a\n * tenant other than `resolveFromAuth(ctx)` (or an unresolvable auth\n * principal) returns `null` (framework emits `ACCOUNT_NOT_FOUND`). Use\n * this when a credential must NOT be able to reach another tenant's\n * account by supplying its ref.\n *\n * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always\n * enforce the tenant gate regardless of this setting.\n */\n refAccess: 'ref-routed' | 'auth-scoped';\n\n /**\n * Path 1: account ref carries `account_id` OR `(brand, operator)`.\n * Resolve to the tenant the ref points at. Return null if the ref is\n * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).\n *\n * Under `refAccess: 'ref-routed'` this resolves independent of who the caller\n * is. Under `refAccess: 'auth-scoped'` a ref pointing at a tenant other than\n * the caller's is rejected.\n *\n * Receives the full `AccountReference` so adopters can route on\n * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the\n * `account_id` arm of the discriminated union.\n */\n resolveByRef(ref: AccountReference): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Path 2: no account ref on the wire (`get_brand_identity`, `get_rights`,\n * `provide_performance_feedback`, `list_creative_formats`). Derive the\n * tenant from the auth principal. Return null if no principal is\n * resolvable (no auth, no `agentRegistry`, principal not registered).\n *\n * Used by both `accounts.resolve(undefined, ctx)` (no-account tools)\n * AND the tenant-isolation gate on per-entry tools — `null` here\n * means EVERY entry on `sync_accounts` / `sync_governance` fails\n * `PERMISSION_DENIED` (fail-closed).\n */\n resolveFromAuth(ctx: ResolveContext): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Stable identity for tenant-equality checks. The helper compares\n * `tenantId(authTenant) === tenantId(entryTenant)` to enforce isolation.\n * Reference equality is fragile (Postgres-backed stores hand back fresh\n * objects each fetch); a stable string id closes that gap.\n */\n tenantId(tenant: TTenant): string;\n\n /**\n * Project `(tenant, ref)` to the framework `Account<TCtxMeta>`. Called by\n * `accounts.resolve` after tenant resolution. Adopters thread sandbox\n * routing here (`sandbox: ref?.sandbox`), pin `ctx_metadata` for\n * downstream handlers, and shape `name` / `operator` / `brand` to match\n * the wire echo conventions their buyers expect.\n *\n * `ref` is `undefined` on Path-2 (no-account tools) — adopters returning\n * a synthetic publisher-wide singleton omit `ref?.sandbox` (no sandbox\n * boundary applies).\n */\n tenantToAccount(\n tenant: TTenant,\n ref: AccountReference | undefined,\n ctx: ResolveContext\n ): Account<TCtxMeta> | Promise<Account<TCtxMeta>>;\n\n /**\n * Per-entry `sync_accounts` storage callback. Called for each entry whose\n * `(authTenant === entryTenant)` check passed. Receives the resolved\n * tenant + the original ref + ctx. Returns a `SyncAccountsResultRow`.\n *\n * Cross-tenant entries and unknown-ref entries never reach this callback\n * — the helper builds `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` rows for\n * those before invoking your code. The adopter's only job is the actual\n * upsert.\n *\n * Optional. Omit if your platform doesn't claim `sync_accounts`; the\n * helper leaves `accounts.upsert` undefined and the framework returns\n * `UNSUPPORTED_FEATURE`.\n */\n upsertRow?(\n tenant: TTenant,\n ref: AccountReference,\n ctx: ResolveContext\n ): SyncAccountsResultRow | Promise<SyncAccountsResultRow>;\n\n /**\n * Per-entry `sync_governance` storage callback. Same gating rules as\n * `upsertRow` — cross-tenant entries are rejected before reaching this\n * code. Adopters persist the buyer's governance-agent binding (including\n * write-only `authentication.credentials`, which the framework strips\n * from the response automatically — see `toWireSyncGovernanceRow`).\n *\n * Optional. Omit if your platform doesn't claim `sync_governance`.\n */\n syncGovernanceRow?(\n tenant: TTenant,\n entry: SyncGovernanceEntry,\n ctx: ResolveContext\n ): SyncGovernanceRow | Promise<SyncGovernanceRow>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` whose `resolve` / `upsert` /\n * `syncGovernance` methods enforce tenant isolation.\n *\n * The helper produces:\n *\n * - `accounts.resolve(ref, ctx)` — calls `resolveByRef(ref)` when `ref` is\n * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.\n * Returns `null` if the resolver returned `null` (framework emits\n * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats\n * absence as \"no tenant\" for tools that don't). Under\n * `refAccess: 'ref-routed'` this path does NOT check the caller against the\n * ref's tenant; `refAccess: 'auth-scoped'` fails closed on a cross-tenant ref.\n *\n * - `accounts.upsert(refs, ctx)` — for each ref:\n * 1. Resolve the entry's tenant via `resolveByRef`.\n * 2. Resolve the auth principal's tenant via `resolveFromAuth(ctx)`\n * (computed once per request).\n * 3. If the entry tenant is unknown, emit `ACCOUNT_NOT_FOUND`.\n * 4. If the auth tenant is unknown OR differs from the entry tenant,\n * emit `PERMISSION_DENIED` (fail-closed).\n * 5. Otherwise, invoke the adopter's `upsertRow`.\n *\n * - `accounts.syncGovernance(entries, ctx)` — same gating as `upsert`,\n * shaped for the `SyncGovernanceResponseRow` arm (`status: 'failed'`\n * with per-entry `errors`).\n *\n * `accounts.list` and `accounts.reportUsage` / `accounts.getAccountFinancials`\n * are NOT generated by this helper — those tools have shapes (cursor\n * pagination; per-row account refs spanning multiple tenants in\n * `report_usage`) that don't fit the per-entry-then-row pattern. Adopters\n * who claim those capabilities extend the returned store with\n * `Object.assign`:\n *\n * ```ts\n * const accounts = Object.assign(\n * createTenantStore<TenantState, TenantMeta>({...}),\n * { list: async (filter, ctx) => { ... } }\n * );\n * ```\n *\n * **Direct mutation of `upsert` / `syncGovernance` is locked.** The helper\n * makes those properties non-writable (`Object.defineProperty`) so an\n * adopter who writes `accounts.upsert = customHandler` after construction\n * gets a TypeError instead of silently bypassing the tenant gate. If you\n * really need a different `upsert`, don't use the helper — write a plain\n * `AccountStore` and own the gate.\n */\nexport function createTenantStore<TTenant, TCtxMeta = Record<string, unknown>>(\n config: TenantStoreConfig<TTenant, TCtxMeta>\n): AccountStore<TCtxMeta> {\n // `refAccess` is required in the type, but the type only protects TypeScript\n // callers — a JS adopter or an `as any` cast would otherwise fall through to\n // the permissive branch silently, which is the exact failure this field exists\n // to prevent. Refuse at construction instead.\n if (config.refAccess !== 'ref-routed' && config.refAccess !== 'auth-scoped') {\n throw new Error(\n 'createTenantStore: `refAccess` is required and must be \"ref-routed\" or \"auth-scoped\". ' +\n 'There is deliberately no default — the safe value depends on whether one credential is ' +\n 'supposed to span tenants. Use \"auth-scoped\" when a credential must NOT reach another ' +\n 'tenant\\'s account by naming its ref (most multi-tenant deployments), or \"ref-routed\" for ' +\n 'an agency hub whose single credential legitimately spans tenants. Note this governs ' +\n '`resolve` only — which is the account path for create_media_buy and update_media_buy — ' +\n 'while `upsert` / `syncGovernance` are gated either way.'\n );\n }\n const store: AccountStore<TCtxMeta> = {\n resolve: async (ref, ctx) => {\n const resolveCtx = ctx ?? {};\n if (!ref) {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n if (authTenant == null) return null;\n return await config.tenantToAccount(authTenant, ref, resolveCtx);\n }\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) return null;\n if (config.refAccess === 'auth-scoped') {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n // Fail-closed: an unresolvable principal OR a ref pointing at a\n // different tenant is treated as not found — a caller cannot reach\n // another tenant's account by naming its ref.\n if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {\n return null;\n }\n }\n return await config.tenantToAccount(entryTenant, ref, resolveCtx);\n },\n };\n\n if (config.upsertRow) {\n const upsertRow = config.upsertRow;\n const upsert: NonNullable<AccountStore<TCtxMeta>['upsert']> = async (refs, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n // Sequential, not Promise.all: adopter `upsertRow` callbacks\n // commonly mutate shared tenant state (the multi-tenant adapter's\n // `tenant.accounts.set(...)` is the canonical example). Concurrent\n // invocations against the same tenant are an entropy source the\n // helper shouldn't introduce. Adopters who want parallel writes\n // can fan out inside their callback against an upstream that\n // tolerates it.\n const rows: SyncAccountsResultRow[] = [];\n for (const ref of refs) {\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) {\n rows.push(buildSyncAccountsFailedRow(ref, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(ref)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncAccountsFailedRow(ref, 'PERMISSION_DENIED', permissionDeniedMessage(ref)));\n continue;\n }\n rows.push(await upsertRow(entryTenant, ref, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'upsert', { value: upsert, writable: false, configurable: false, enumerable: true });\n }\n\n if (config.syncGovernanceRow) {\n const syncGovernanceRow = config.syncGovernanceRow;\n const syncGovernance: NonNullable<AccountStore<TCtxMeta>['syncGovernance']> = async (entries, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n const rows: SyncGovernanceRow[] = [];\n for (const entry of entries) {\n const entryTenant = await config.resolveByRef(entry.account);\n if (entryTenant == null) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(entry.account)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'PERMISSION_DENIED', permissionDeniedMessage(entry.account)));\n continue;\n }\n rows.push(await syncGovernanceRow(entryTenant, entry, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'syncGovernance', {\n value: syncGovernance,\n writable: false,\n configurable: false,\n enumerable: true,\n });\n }\n\n return store;\n}\n\n/**\n * Read fields from an `AccountReference` without per-arm narrowing. The wire\n * type is a discriminated union (`{account_id} | {brand, operator}`) — schema\n * validation upstream guarantees one arm is populated, so widening to an\n * all-optional record is safe and saves a `'in' ref ? ref.x : fallback` dance\n * inside `tenantToAccount` / `resolveByRef` / failed-row builders.\n *\n * Use inside adopter `createTenantStore({...})` callbacks to read\n * `ref.operator`, `ref.brand?.domain`, or `ref.account_id` without inline\n * casts. This is the same helper the framework uses internally for its\n * `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` row construction.\n *\n * ```ts\n * tenantToAccount: (tenant, ref, ctx) => {\n * const r = narrowAccountRef(ref);\n * return {\n * id: tenant.id,\n * operator: r?.operator ?? ctx?.agent?.agent_url ?? 'derived',\n * ...(r?.brand?.domain && { brand: { domain: r.brand.domain } }),\n *\n * };\n * }\n * ```\n *\n * Returns `undefined` on `undefined` input — the no-account-tool path.\n */\nexport function narrowAccountRef(ref: AccountReference): {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n};\nexport function narrowAccountRef(ref: undefined): undefined;\nexport function narrowAccountRef(ref: AccountReference | undefined):\n | {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n }\n | undefined;\nexport function narrowAccountRef(\n ref: AccountReference | undefined\n): { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean } | undefined {\n if (ref === undefined) return undefined;\n return ref as { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean };\n}\n\n/**\n * Build a failed `sync_accounts` row when the helper rejects an entry\n * (cross-tenant or unknown ref). The wire schema requires `brand` +\n * `operator` on every row, so when the input ref is `account_id`-only\n * we synthesize `'unknown'` placeholders — the buyer's `errors[0].code`\n * is the actionable signal; `brand` / `operator` here are wire-required\n * scaffolding, not authoritative echoes.\n */\nfunction buildSyncAccountsFailedRow(\n ref: AccountReference,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncAccountsResultRow {\n const r = narrowAccountRef(ref);\n return {\n brand: { domain: r.brand?.domain ?? 'unknown.example' },\n operator: r.operator ?? 'unknown',\n action: 'failed',\n status: 'rejected',\n errors: [{ code, message }],\n ...(r.account_id != null && { account_id: r.account_id }),\n };\n}\n\nfunction buildSyncGovernanceFailedRow(\n entry: SyncGovernanceEntry,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncGovernanceRow {\n return {\n account: entry.account,\n status: 'failed',\n errors: [{ code, message }],\n };\n}\n\nfunction accountNotFoundMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n if (r.account_id) return `Unknown account_id: ${r.account_id}`;\n if (r.operator) return `Unknown operator: ${r.operator}`;\n return 'Unknown account reference';\n}\n\nfunction permissionDeniedMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n const subject = r.operator ?? r.account_id ?? 'this account';\n return `Buyer agent has no authority over '${subject}' (tenant mismatch or auth principal not registered).`;\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,9 @@
1
1
  function createTenantStore(config) {
2
+ if (config.refAccess !== "ref-routed" && config.refAccess !== "auth-scoped") {
3
+ throw new Error(
4
+ 'createTenantStore: `refAccess` is required and must be "ref-routed" or "auth-scoped". There is deliberately no default \u2014 the safe value depends on whether one credential is supposed to span tenants. Use "auth-scoped" when a credential must NOT reach another tenant\'s account by naming its ref (most multi-tenant deployments), or "ref-routed" for an agency hub whose single credential legitimately spans tenants. Note this governs `resolve` only \u2014 which is the account path for create_media_buy and update_media_buy \u2014 while `upsert` / `syncGovernance` are gated either way.'
5
+ );
6
+ }
2
7
  const store = {
3
8
  resolve: async (ref, ctx) => {
4
9
  const resolveCtx = ctx ?? {};
@@ -9,7 +14,7 @@ function createTenantStore(config) {
9
14
  }
10
15
  const entryTenant = await config.resolveByRef(ref);
11
16
  if (entryTenant == null) return null;
12
- if ((config.refAccess ?? "ref-routed") === "auth-scoped") {
17
+ if (config.refAccess === "auth-scoped") {
13
18
  const authTenant = await config.resolveFromAuth(resolveCtx);
14
19
  if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {
15
20
  return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"sourcesContent":["/**\n * `createTenantStore` — opinionated `AccountStore` builder for multi-tenant\n * adapters. Canonicalizes the two-path resolution shape (operator-routed\n * for tools that carry `account` on the wire; auth-derived for tools that\n * don't) and bakes in the per-entry tenant-isolation gate on the mutating\n * account-sync tools (`sync_accounts` / `sync_governance`) that adopters\n * historically had to write — and silently fail to write — by hand.\n *\n * NOTE: `accounts.resolve` is NOT gated by default (`refAccess: 'ref-routed'`)\n * — it returns whatever tenant the buyer's ref points at, which is correct\n * for the agency-hub model where one credential spans tenants. Deployments\n * where a credential must NOT reach another tenant's account set\n * `refAccess: 'auth-scoped'` (gates `resolve` fail-closed) or compose a\n * `resolve-presets` guard. See {@link TenantStoreConfig.refAccess}.\n *\n * Full walkthrough with same-tenant invariant + production caveats:\n * `skills/build-holdco-agent/SKILL.md`. Worked example:\n * `examples/hello_seller_adapter_multi_tenant.ts`.\n *\n * Status: Preview / 6.x.\n *\n * @public\n */\n\nimport type { ResolveContext, Account, AccountStore, SyncAccountsResultRow } from './account';\nimport type { AccountReference, SyncGovernanceRequest, SyncGovernanceSuccess } from '../../types/tools.generated';\n\ntype SyncGovernanceEntry = SyncGovernanceRequest['accounts'][number];\ntype SyncGovernanceRow = SyncGovernanceSuccess['accounts'][number];\n\n/**\n * Adopter contract for `createTenantStore`. Every callback is sync OR async;\n * the helper awaits.\n *\n * Tenant isolation is enforced by comparing `tenantId(authTenant)` against\n * `tenantId(entryTenant)` per-entry on `sync_accounts` / `sync_governance`.\n * Mismatches produce a `'failed'` row with `code: 'PERMISSION_DENIED'` —\n * the adopter's `upsertRow` / `syncGovernanceRow` callbacks NEVER see a\n * cross-tenant entry. Fail-closed when `resolveFromAuth` returns null\n * (unknown principal): every entry fails `PERMISSION_DENIED` regardless of\n * its operator. Don't fork this around to fail-open — adopters who copied\n * the prior fail-open shape (`if (homeTenantId && tenantId !== homeTenantId)`)\n * silently disabled isolation when a credential lacked a tenant binding.\n *\n * @template TTenant Adopter's tenant model (e.g., `TenantState`, a row\n * from a `tenants` table). Compared via `tenantId`,\n * not by reference.\n * @template TCtxMeta Shape of `Account.ctx_metadata`. Threads through to\n * every specialism handler via `ctx.account.ctx_metadata`.\n */\nexport interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>> {\n /**\n * Controls whether a buyer-supplied account ref on `accounts.resolve` is\n * gated against the authenticated principal's tenant.\n *\n * - `'ref-routed'` (default) — `resolve` returns whatever tenant the ref\n * points at, WITHOUT checking the caller. This is correct for the\n * agency-hub / account-routed model where one credential legitimately\n * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).\n * In this mode `resolve` performs NO isolation check — any authenticated\n * caller can resolve any tenant's account by naming its `account_id` /\n * `operator`. Isolation for such deployments must be layered on top with\n * a `resolve-presets` guard (`requireAccountMatch` /\n * `requireAdvertiserMatch` / `requireOrgScope`) via `composeMethod`.\n * - `'auth-scoped'` — `resolve` fails closed: a ref that resolves to a\n * tenant other than `resolveFromAuth(ctx)` (or an unresolvable auth\n * principal) returns `null` (framework emits `ACCOUNT_NOT_FOUND`). Use\n * this when a credential must NOT be able to reach another tenant's\n * account by supplying its ref.\n *\n * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always\n * enforce the tenant gate regardless of this setting.\n */\n refAccess?: 'ref-routed' | 'auth-scoped';\n\n /**\n * Path 1: account ref carries `account_id` OR `(brand, operator)`.\n * Resolve to the tenant the ref points at. Return null if the ref is\n * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).\n *\n * By default (`refAccess: 'ref-routed'`) this resolves independent of who\n * the caller is. Set `refAccess: 'auth-scoped'` to make `resolve` reject a\n * ref that points at a tenant other than the caller's.\n *\n * Receives the full `AccountReference` so adopters can route on\n * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the\n * `account_id` arm of the discriminated union.\n */\n resolveByRef(ref: AccountReference): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Path 2: no account ref on the wire (`get_brand_identity`, `get_rights`,\n * `provide_performance_feedback`, `list_creative_formats`). Derive the\n * tenant from the auth principal. Return null if no principal is\n * resolvable (no auth, no `agentRegistry`, principal not registered).\n *\n * Used by both `accounts.resolve(undefined, ctx)` (no-account tools)\n * AND the tenant-isolation gate on per-entry tools — `null` here\n * means EVERY entry on `sync_accounts` / `sync_governance` fails\n * `PERMISSION_DENIED` (fail-closed).\n */\n resolveFromAuth(ctx: ResolveContext): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Stable identity for tenant-equality checks. The helper compares\n * `tenantId(authTenant) === tenantId(entryTenant)` to enforce isolation.\n * Reference equality is fragile (Postgres-backed stores hand back fresh\n * objects each fetch); a stable string id closes that gap.\n */\n tenantId(tenant: TTenant): string;\n\n /**\n * Project `(tenant, ref)` to the framework `Account<TCtxMeta>`. Called by\n * `accounts.resolve` after tenant resolution. Adopters thread sandbox\n * routing here (`sandbox: ref?.sandbox`), pin `ctx_metadata` for\n * downstream handlers, and shape `name` / `operator` / `brand` to match\n * the wire echo conventions their buyers expect.\n *\n * `ref` is `undefined` on Path-2 (no-account tools) — adopters returning\n * a synthetic publisher-wide singleton omit `ref?.sandbox` (no sandbox\n * boundary applies).\n */\n tenantToAccount(\n tenant: TTenant,\n ref: AccountReference | undefined,\n ctx: ResolveContext\n ): Account<TCtxMeta> | Promise<Account<TCtxMeta>>;\n\n /**\n * Per-entry `sync_accounts` storage callback. Called for each entry whose\n * `(authTenant === entryTenant)` check passed. Receives the resolved\n * tenant + the original ref + ctx. Returns a `SyncAccountsResultRow`.\n *\n * Cross-tenant entries and unknown-ref entries never reach this callback\n * — the helper builds `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` rows for\n * those before invoking your code. The adopter's only job is the actual\n * upsert.\n *\n * Optional. Omit if your platform doesn't claim `sync_accounts`; the\n * helper leaves `accounts.upsert` undefined and the framework returns\n * `UNSUPPORTED_FEATURE`.\n */\n upsertRow?(\n tenant: TTenant,\n ref: AccountReference,\n ctx: ResolveContext\n ): SyncAccountsResultRow | Promise<SyncAccountsResultRow>;\n\n /**\n * Per-entry `sync_governance` storage callback. Same gating rules as\n * `upsertRow` — cross-tenant entries are rejected before reaching this\n * code. Adopters persist the buyer's governance-agent binding (including\n * write-only `authentication.credentials`, which the framework strips\n * from the response automatically — see `toWireSyncGovernanceRow`).\n *\n * Optional. Omit if your platform doesn't claim `sync_governance`.\n */\n syncGovernanceRow?(\n tenant: TTenant,\n entry: SyncGovernanceEntry,\n ctx: ResolveContext\n ): SyncGovernanceRow | Promise<SyncGovernanceRow>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` whose `resolve` / `upsert` /\n * `syncGovernance` methods enforce tenant isolation.\n *\n * The helper produces:\n *\n * - `accounts.resolve(ref, ctx)` — calls `resolveByRef(ref)` when `ref` is\n * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.\n * Returns `null` if the resolver returned `null` (framework emits\n * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats\n * absence as \"no tenant\" for tools that don't). By default this path does\n * NOT check the caller against the ref's tenant — set\n * `refAccess: 'auth-scoped'` to fail closed on a cross-tenant ref.\n *\n * - `accounts.upsert(refs, ctx)` — for each ref:\n * 1. Resolve the entry's tenant via `resolveByRef`.\n * 2. Resolve the auth principal's tenant via `resolveFromAuth(ctx)`\n * (computed once per request).\n * 3. If the entry tenant is unknown, emit `ACCOUNT_NOT_FOUND`.\n * 4. If the auth tenant is unknown OR differs from the entry tenant,\n * emit `PERMISSION_DENIED` (fail-closed).\n * 5. Otherwise, invoke the adopter's `upsertRow`.\n *\n * - `accounts.syncGovernance(entries, ctx)` — same gating as `upsert`,\n * shaped for the `SyncGovernanceResponseRow` arm (`status: 'failed'`\n * with per-entry `errors`).\n *\n * `accounts.list` and `accounts.reportUsage` / `accounts.getAccountFinancials`\n * are NOT generated by this helper — those tools have shapes (cursor\n * pagination; per-row account refs spanning multiple tenants in\n * `report_usage`) that don't fit the per-entry-then-row pattern. Adopters\n * who claim those capabilities extend the returned store with\n * `Object.assign`:\n *\n * ```ts\n * const accounts = Object.assign(\n * createTenantStore<TenantState, TenantMeta>({...}),\n * { list: async (filter, ctx) => { ... } }\n * );\n * ```\n *\n * **Direct mutation of `upsert` / `syncGovernance` is locked.** The helper\n * makes those properties non-writable (`Object.defineProperty`) so an\n * adopter who writes `accounts.upsert = customHandler` after construction\n * gets a TypeError instead of silently bypassing the tenant gate. If you\n * really need a different `upsert`, don't use the helper — write a plain\n * `AccountStore` and own the gate.\n */\nexport function createTenantStore<TTenant, TCtxMeta = Record<string, unknown>>(\n config: TenantStoreConfig<TTenant, TCtxMeta>\n): AccountStore<TCtxMeta> {\n const store: AccountStore<TCtxMeta> = {\n resolve: async (ref, ctx) => {\n const resolveCtx = ctx ?? {};\n if (!ref) {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n if (authTenant == null) return null;\n return await config.tenantToAccount(authTenant, ref, resolveCtx);\n }\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) return null;\n if ((config.refAccess ?? 'ref-routed') === 'auth-scoped') {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n // Fail-closed: an unresolvable principal OR a ref pointing at a\n // different tenant is treated as not found — a caller cannot reach\n // another tenant's account by naming its ref.\n if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {\n return null;\n }\n }\n return await config.tenantToAccount(entryTenant, ref, resolveCtx);\n },\n };\n\n if (config.upsertRow) {\n const upsertRow = config.upsertRow;\n const upsert: NonNullable<AccountStore<TCtxMeta>['upsert']> = async (refs, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n // Sequential, not Promise.all: adopter `upsertRow` callbacks\n // commonly mutate shared tenant state (the multi-tenant adapter's\n // `tenant.accounts.set(...)` is the canonical example). Concurrent\n // invocations against the same tenant are an entropy source the\n // helper shouldn't introduce. Adopters who want parallel writes\n // can fan out inside their callback against an upstream that\n // tolerates it.\n const rows: SyncAccountsResultRow[] = [];\n for (const ref of refs) {\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) {\n rows.push(buildSyncAccountsFailedRow(ref, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(ref)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncAccountsFailedRow(ref, 'PERMISSION_DENIED', permissionDeniedMessage(ref)));\n continue;\n }\n rows.push(await upsertRow(entryTenant, ref, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'upsert', { value: upsert, writable: false, configurable: false, enumerable: true });\n }\n\n if (config.syncGovernanceRow) {\n const syncGovernanceRow = config.syncGovernanceRow;\n const syncGovernance: NonNullable<AccountStore<TCtxMeta>['syncGovernance']> = async (entries, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n const rows: SyncGovernanceRow[] = [];\n for (const entry of entries) {\n const entryTenant = await config.resolveByRef(entry.account);\n if (entryTenant == null) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(entry.account)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'PERMISSION_DENIED', permissionDeniedMessage(entry.account)));\n continue;\n }\n rows.push(await syncGovernanceRow(entryTenant, entry, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'syncGovernance', {\n value: syncGovernance,\n writable: false,\n configurable: false,\n enumerable: true,\n });\n }\n\n return store;\n}\n\n/**\n * Read fields from an `AccountReference` without per-arm narrowing. The wire\n * type is a discriminated union (`{account_id} | {brand, operator}`) — schema\n * validation upstream guarantees one arm is populated, so widening to an\n * all-optional record is safe and saves a `'in' ref ? ref.x : fallback` dance\n * inside `tenantToAccount` / `resolveByRef` / failed-row builders.\n *\n * Use inside adopter `createTenantStore({...})` callbacks to read\n * `ref.operator`, `ref.brand?.domain`, or `ref.account_id` without inline\n * casts. This is the same helper the framework uses internally for its\n * `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` row construction.\n *\n * ```ts\n * tenantToAccount: (tenant, ref, ctx) => {\n * const r = narrowAccountRef(ref);\n * return {\n * id: tenant.id,\n * operator: r?.operator ?? ctx?.agent?.agent_url ?? 'derived',\n * ...(r?.brand?.domain && { brand: { domain: r.brand.domain } }),\n *\n * };\n * }\n * ```\n *\n * Returns `undefined` on `undefined` input — the no-account-tool path.\n */\nexport function narrowAccountRef(ref: AccountReference): {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n};\nexport function narrowAccountRef(ref: undefined): undefined;\nexport function narrowAccountRef(ref: AccountReference | undefined):\n | {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n }\n | undefined;\nexport function narrowAccountRef(\n ref: AccountReference | undefined\n): { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean } | undefined {\n if (ref === undefined) return undefined;\n return ref as { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean };\n}\n\n/**\n * Build a failed `sync_accounts` row when the helper rejects an entry\n * (cross-tenant or unknown ref). The wire schema requires `brand` +\n * `operator` on every row, so when the input ref is `account_id`-only\n * we synthesize `'unknown'` placeholders — the buyer's `errors[0].code`\n * is the actionable signal; `brand` / `operator` here are wire-required\n * scaffolding, not authoritative echoes.\n */\nfunction buildSyncAccountsFailedRow(\n ref: AccountReference,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncAccountsResultRow {\n const r = narrowAccountRef(ref);\n return {\n brand: { domain: r.brand?.domain ?? 'unknown.example' },\n operator: r.operator ?? 'unknown',\n action: 'failed',\n status: 'rejected',\n errors: [{ code, message }],\n ...(r.account_id != null && { account_id: r.account_id }),\n };\n}\n\nfunction buildSyncGovernanceFailedRow(\n entry: SyncGovernanceEntry,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncGovernanceRow {\n return {\n account: entry.account,\n status: 'failed',\n errors: [{ code, message }],\n };\n}\n\nfunction accountNotFoundMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n if (r.account_id) return `Unknown account_id: ${r.account_id}`;\n if (r.operator) return `Unknown operator: ${r.operator}`;\n return 'Unknown account reference';\n}\n\nfunction permissionDeniedMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n const subject = r.operator ?? r.account_id ?? 'this account';\n return `Buyer agent has no authority over '${subject}' (tenant mismatch or auth principal not registered).`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../src/lib/server/decisioning/tenant-store.ts"],"sourcesContent":["/**\n * `createTenantStore` — opinionated `AccountStore` builder for multi-tenant\n * adapters. Canonicalizes the two-path resolution shape (operator-routed\n * for tools that carry `account` on the wire; auth-derived for tools that\n * don't) and bakes in the per-entry tenant-isolation gate on the mutating\n * account-sync tools (`sync_accounts` / `sync_governance`) that adopters\n * historically had to write — and silently fail to write — by hand.\n *\n * NOTE: `refAccess` is REQUIRED and has no default, because the safe value\n * depends on a fact only the adopter knows. `'ref-routed'` returns whatever\n * tenant the buyer's ref points at — correct for the agency-hub model where one\n * credential legitimately spans tenants, and a cross-tenant spend hole where it\n * doesn't. `'auth-scoped'` gates `resolve` fail-closed.\n *\n * `refAccess` governs `resolve` ONLY. `upsert` / `syncGovernance` enforce the\n * tenant gate either way — so an adopter who has verified those is NOT covered\n * on `resolve`, and `resolve` is the account path for `create_media_buy` and\n * `update_media_buy`. See {@link TenantStoreConfig.refAccess}.\n *\n * Full walkthrough with same-tenant invariant + production caveats:\n * `skills/build-holdco-agent/SKILL.md`. Worked example:\n * `examples/hello_seller_adapter_multi_tenant.ts`.\n *\n * Status: Preview / 6.x.\n *\n * @public\n */\n\nimport type { ResolveContext, Account, AccountStore, SyncAccountsResultRow } from './account';\nimport type { AccountReference, SyncGovernanceRequest, SyncGovernanceSuccess } from '../../types/tools.generated';\n\ntype SyncGovernanceEntry = SyncGovernanceRequest['accounts'][number];\ntype SyncGovernanceRow = SyncGovernanceSuccess['accounts'][number];\n\n/**\n * Adopter contract for `createTenantStore`. Every callback is sync OR async;\n * the helper awaits.\n *\n * Tenant isolation is enforced by comparing `tenantId(authTenant)` against\n * `tenantId(entryTenant)` per-entry on `sync_accounts` / `sync_governance`.\n * Mismatches produce a `'failed'` row with `code: 'PERMISSION_DENIED'` —\n * the adopter's `upsertRow` / `syncGovernanceRow` callbacks NEVER see a\n * cross-tenant entry. Fail-closed when `resolveFromAuth` returns null\n * (unknown principal): every entry fails `PERMISSION_DENIED` regardless of\n * its operator. Don't fork this around to fail-open — adopters who copied\n * the prior fail-open shape (`if (homeTenantId && tenantId !== homeTenantId)`)\n * silently disabled isolation when a credential lacked a tenant binding.\n *\n * @template TTenant Adopter's tenant model (e.g., `TenantState`, a row\n * from a `tenants` table). Compared via `tenantId`,\n * not by reference.\n * @template TCtxMeta Shape of `Account.ctx_metadata`. Threads through to\n * every specialism handler via `ctx.account.ctx_metadata`.\n */\nexport interface TenantStoreConfig<TTenant, TCtxMeta = Record<string, unknown>> {\n /**\n * Controls whether a buyer-supplied account ref on `accounts.resolve` is\n * gated against the authenticated principal's tenant.\n *\n * REQUIRED, deliberately with no default. Both values are correct for some\n * deployments and catastrophic for others, and nothing in the code can tell\n * which one you are: `'ref-routed'` is right for an agency hub whose single\n * credential legitimately spans tenants, and a cross-tenant spend hole for a\n * hub whose tenants are unrelated clients. A default would silently pick one\n * of those for you. Making it required turns that into a `tsc` error you\n * resolve once, at construction, instead of a runtime surprise.\n *\n * - `'ref-routed'` — `resolve` returns whatever tenant the ref\n * points at, WITHOUT checking the caller. This is correct for the\n * agency-hub / account-routed model where one credential legitimately\n * spans tenants (see `examples/hello_seller_adapter_multi_tenant.ts`).\n * In this mode `resolve` performs NO isolation check — any authenticated\n * caller can resolve any tenant's account by naming its `account_id` /\n * `operator`. Isolation for such deployments must be layered on top with\n * a `resolve-presets` guard (`requireAccountMatch` /\n * `requireAdvertiserMatch` / `requireOrgScope`) via `composeMethod`.\n * - `'auth-scoped'` — `resolve` fails closed: a ref that resolves to a\n * tenant other than `resolveFromAuth(ctx)` (or an unresolvable auth\n * principal) returns `null` (framework emits `ACCOUNT_NOT_FOUND`). Use\n * this when a credential must NOT be able to reach another tenant's\n * account by supplying its ref.\n *\n * This flag governs ONLY `resolve`. `upsert` / `syncGovernance` always\n * enforce the tenant gate regardless of this setting.\n */\n refAccess: 'ref-routed' | 'auth-scoped';\n\n /**\n * Path 1: account ref carries `account_id` OR `(brand, operator)`.\n * Resolve to the tenant the ref points at. Return null if the ref is\n * unknown (helper emits `ACCOUNT_NOT_FOUND` for that row).\n *\n * Under `refAccess: 'ref-routed'` this resolves independent of who the caller\n * is. Under `refAccess: 'auth-scoped'` a ref pointing at a tenant other than\n * the caller's is rejected.\n *\n * Receives the full `AccountReference` so adopters can route on\n * `ref.sandbox` (Pattern 2: separate sandbox tenant) or read the\n * `account_id` arm of the discriminated union.\n */\n resolveByRef(ref: AccountReference): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Path 2: no account ref on the wire (`get_brand_identity`, `get_rights`,\n * `provide_performance_feedback`, `list_creative_formats`). Derive the\n * tenant from the auth principal. Return null if no principal is\n * resolvable (no auth, no `agentRegistry`, principal not registered).\n *\n * Used by both `accounts.resolve(undefined, ctx)` (no-account tools)\n * AND the tenant-isolation gate on per-entry tools — `null` here\n * means EVERY entry on `sync_accounts` / `sync_governance` fails\n * `PERMISSION_DENIED` (fail-closed).\n */\n resolveFromAuth(ctx: ResolveContext): TTenant | null | Promise<TTenant | null>;\n\n /**\n * Stable identity for tenant-equality checks. The helper compares\n * `tenantId(authTenant) === tenantId(entryTenant)` to enforce isolation.\n * Reference equality is fragile (Postgres-backed stores hand back fresh\n * objects each fetch); a stable string id closes that gap.\n */\n tenantId(tenant: TTenant): string;\n\n /**\n * Project `(tenant, ref)` to the framework `Account<TCtxMeta>`. Called by\n * `accounts.resolve` after tenant resolution. Adopters thread sandbox\n * routing here (`sandbox: ref?.sandbox`), pin `ctx_metadata` for\n * downstream handlers, and shape `name` / `operator` / `brand` to match\n * the wire echo conventions their buyers expect.\n *\n * `ref` is `undefined` on Path-2 (no-account tools) — adopters returning\n * a synthetic publisher-wide singleton omit `ref?.sandbox` (no sandbox\n * boundary applies).\n */\n tenantToAccount(\n tenant: TTenant,\n ref: AccountReference | undefined,\n ctx: ResolveContext\n ): Account<TCtxMeta> | Promise<Account<TCtxMeta>>;\n\n /**\n * Per-entry `sync_accounts` storage callback. Called for each entry whose\n * `(authTenant === entryTenant)` check passed. Receives the resolved\n * tenant + the original ref + ctx. Returns a `SyncAccountsResultRow`.\n *\n * Cross-tenant entries and unknown-ref entries never reach this callback\n * — the helper builds `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` rows for\n * those before invoking your code. The adopter's only job is the actual\n * upsert.\n *\n * Optional. Omit if your platform doesn't claim `sync_accounts`; the\n * helper leaves `accounts.upsert` undefined and the framework returns\n * `UNSUPPORTED_FEATURE`.\n */\n upsertRow?(\n tenant: TTenant,\n ref: AccountReference,\n ctx: ResolveContext\n ): SyncAccountsResultRow | Promise<SyncAccountsResultRow>;\n\n /**\n * Per-entry `sync_governance` storage callback. Same gating rules as\n * `upsertRow` — cross-tenant entries are rejected before reaching this\n * code. Adopters persist the buyer's governance-agent binding (including\n * write-only `authentication.credentials`, which the framework strips\n * from the response automatically — see `toWireSyncGovernanceRow`).\n *\n * Optional. Omit if your platform doesn't claim `sync_governance`.\n */\n syncGovernanceRow?(\n tenant: TTenant,\n entry: SyncGovernanceEntry,\n ctx: ResolveContext\n ): SyncGovernanceRow | Promise<SyncGovernanceRow>;\n}\n\n/**\n * Build an `AccountStore<TCtxMeta>` whose `resolve` / `upsert` /\n * `syncGovernance` methods enforce tenant isolation.\n *\n * The helper produces:\n *\n * - `accounts.resolve(ref, ctx)` — calls `resolveByRef(ref)` when `ref` is\n * set, otherwise `resolveFromAuth(ctx)`. Projects via `tenantToAccount`.\n * Returns `null` if the resolver returned `null` (framework emits\n * `ACCOUNT_NOT_FOUND` for tools that require an account, or treats\n * absence as \"no tenant\" for tools that don't). Under\n * `refAccess: 'ref-routed'` this path does NOT check the caller against the\n * ref's tenant; `refAccess: 'auth-scoped'` fails closed on a cross-tenant ref.\n *\n * - `accounts.upsert(refs, ctx)` — for each ref:\n * 1. Resolve the entry's tenant via `resolveByRef`.\n * 2. Resolve the auth principal's tenant via `resolveFromAuth(ctx)`\n * (computed once per request).\n * 3. If the entry tenant is unknown, emit `ACCOUNT_NOT_FOUND`.\n * 4. If the auth tenant is unknown OR differs from the entry tenant,\n * emit `PERMISSION_DENIED` (fail-closed).\n * 5. Otherwise, invoke the adopter's `upsertRow`.\n *\n * - `accounts.syncGovernance(entries, ctx)` — same gating as `upsert`,\n * shaped for the `SyncGovernanceResponseRow` arm (`status: 'failed'`\n * with per-entry `errors`).\n *\n * `accounts.list` and `accounts.reportUsage` / `accounts.getAccountFinancials`\n * are NOT generated by this helper — those tools have shapes (cursor\n * pagination; per-row account refs spanning multiple tenants in\n * `report_usage`) that don't fit the per-entry-then-row pattern. Adopters\n * who claim those capabilities extend the returned store with\n * `Object.assign`:\n *\n * ```ts\n * const accounts = Object.assign(\n * createTenantStore<TenantState, TenantMeta>({...}),\n * { list: async (filter, ctx) => { ... } }\n * );\n * ```\n *\n * **Direct mutation of `upsert` / `syncGovernance` is locked.** The helper\n * makes those properties non-writable (`Object.defineProperty`) so an\n * adopter who writes `accounts.upsert = customHandler` after construction\n * gets a TypeError instead of silently bypassing the tenant gate. If you\n * really need a different `upsert`, don't use the helper — write a plain\n * `AccountStore` and own the gate.\n */\nexport function createTenantStore<TTenant, TCtxMeta = Record<string, unknown>>(\n config: TenantStoreConfig<TTenant, TCtxMeta>\n): AccountStore<TCtxMeta> {\n // `refAccess` is required in the type, but the type only protects TypeScript\n // callers — a JS adopter or an `as any` cast would otherwise fall through to\n // the permissive branch silently, which is the exact failure this field exists\n // to prevent. Refuse at construction instead.\n if (config.refAccess !== 'ref-routed' && config.refAccess !== 'auth-scoped') {\n throw new Error(\n 'createTenantStore: `refAccess` is required and must be \"ref-routed\" or \"auth-scoped\". ' +\n 'There is deliberately no default — the safe value depends on whether one credential is ' +\n 'supposed to span tenants. Use \"auth-scoped\" when a credential must NOT reach another ' +\n 'tenant\\'s account by naming its ref (most multi-tenant deployments), or \"ref-routed\" for ' +\n 'an agency hub whose single credential legitimately spans tenants. Note this governs ' +\n '`resolve` only — which is the account path for create_media_buy and update_media_buy — ' +\n 'while `upsert` / `syncGovernance` are gated either way.'\n );\n }\n const store: AccountStore<TCtxMeta> = {\n resolve: async (ref, ctx) => {\n const resolveCtx = ctx ?? {};\n if (!ref) {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n if (authTenant == null) return null;\n return await config.tenantToAccount(authTenant, ref, resolveCtx);\n }\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) return null;\n if (config.refAccess === 'auth-scoped') {\n const authTenant = await config.resolveFromAuth(resolveCtx);\n // Fail-closed: an unresolvable principal OR a ref pointing at a\n // different tenant is treated as not found — a caller cannot reach\n // another tenant's account by naming its ref.\n if (authTenant == null || config.tenantId(authTenant) !== config.tenantId(entryTenant)) {\n return null;\n }\n }\n return await config.tenantToAccount(entryTenant, ref, resolveCtx);\n },\n };\n\n if (config.upsertRow) {\n const upsertRow = config.upsertRow;\n const upsert: NonNullable<AccountStore<TCtxMeta>['upsert']> = async (refs, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n // Sequential, not Promise.all: adopter `upsertRow` callbacks\n // commonly mutate shared tenant state (the multi-tenant adapter's\n // `tenant.accounts.set(...)` is the canonical example). Concurrent\n // invocations against the same tenant are an entropy source the\n // helper shouldn't introduce. Adopters who want parallel writes\n // can fan out inside their callback against an upstream that\n // tolerates it.\n const rows: SyncAccountsResultRow[] = [];\n for (const ref of refs) {\n const entryTenant = await config.resolveByRef(ref);\n if (entryTenant == null) {\n rows.push(buildSyncAccountsFailedRow(ref, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(ref)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncAccountsFailedRow(ref, 'PERMISSION_DENIED', permissionDeniedMessage(ref)));\n continue;\n }\n rows.push(await upsertRow(entryTenant, ref, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'upsert', { value: upsert, writable: false, configurable: false, enumerable: true });\n }\n\n if (config.syncGovernanceRow) {\n const syncGovernanceRow = config.syncGovernanceRow;\n const syncGovernance: NonNullable<AccountStore<TCtxMeta>['syncGovernance']> = async (entries, ctx) => {\n const resolveCtx = ctx ?? {};\n const authTenant = await config.resolveFromAuth(resolveCtx);\n const authTenantKey = authTenant != null ? config.tenantId(authTenant) : undefined;\n const rows: SyncGovernanceRow[] = [];\n for (const entry of entries) {\n const entryTenant = await config.resolveByRef(entry.account);\n if (entryTenant == null) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'ACCOUNT_NOT_FOUND', accountNotFoundMessage(entry.account)));\n continue;\n }\n const entryKey = config.tenantId(entryTenant);\n if (authTenantKey == null || authTenantKey !== entryKey) {\n rows.push(buildSyncGovernanceFailedRow(entry, 'PERMISSION_DENIED', permissionDeniedMessage(entry.account)));\n continue;\n }\n rows.push(await syncGovernanceRow(entryTenant, entry, resolveCtx));\n }\n return rows;\n };\n Object.defineProperty(store, 'syncGovernance', {\n value: syncGovernance,\n writable: false,\n configurable: false,\n enumerable: true,\n });\n }\n\n return store;\n}\n\n/**\n * Read fields from an `AccountReference` without per-arm narrowing. The wire\n * type is a discriminated union (`{account_id} | {brand, operator}`) — schema\n * validation upstream guarantees one arm is populated, so widening to an\n * all-optional record is safe and saves a `'in' ref ? ref.x : fallback` dance\n * inside `tenantToAccount` / `resolveByRef` / failed-row builders.\n *\n * Use inside adopter `createTenantStore({...})` callbacks to read\n * `ref.operator`, `ref.brand?.domain`, or `ref.account_id` without inline\n * casts. This is the same helper the framework uses internally for its\n * `PERMISSION_DENIED` / `ACCOUNT_NOT_FOUND` row construction.\n *\n * ```ts\n * tenantToAccount: (tenant, ref, ctx) => {\n * const r = narrowAccountRef(ref);\n * return {\n * id: tenant.id,\n * operator: r?.operator ?? ctx?.agent?.agent_url ?? 'derived',\n * ...(r?.brand?.domain && { brand: { domain: r.brand.domain } }),\n *\n * };\n * }\n * ```\n *\n * Returns `undefined` on `undefined` input — the no-account-tool path.\n */\nexport function narrowAccountRef(ref: AccountReference): {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n};\nexport function narrowAccountRef(ref: undefined): undefined;\nexport function narrowAccountRef(ref: AccountReference | undefined):\n | {\n account_id?: string;\n operator?: string;\n brand?: { domain?: string };\n sandbox?: boolean;\n }\n | undefined;\nexport function narrowAccountRef(\n ref: AccountReference | undefined\n): { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean } | undefined {\n if (ref === undefined) return undefined;\n return ref as { account_id?: string; operator?: string; brand?: { domain?: string }; sandbox?: boolean };\n}\n\n/**\n * Build a failed `sync_accounts` row when the helper rejects an entry\n * (cross-tenant or unknown ref). The wire schema requires `brand` +\n * `operator` on every row, so when the input ref is `account_id`-only\n * we synthesize `'unknown'` placeholders — the buyer's `errors[0].code`\n * is the actionable signal; `brand` / `operator` here are wire-required\n * scaffolding, not authoritative echoes.\n */\nfunction buildSyncAccountsFailedRow(\n ref: AccountReference,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncAccountsResultRow {\n const r = narrowAccountRef(ref);\n return {\n brand: { domain: r.brand?.domain ?? 'unknown.example' },\n operator: r.operator ?? 'unknown',\n action: 'failed',\n status: 'rejected',\n errors: [{ code, message }],\n ...(r.account_id != null && { account_id: r.account_id }),\n };\n}\n\nfunction buildSyncGovernanceFailedRow(\n entry: SyncGovernanceEntry,\n code: 'ACCOUNT_NOT_FOUND' | 'PERMISSION_DENIED',\n message: string\n): SyncGovernanceRow {\n return {\n account: entry.account,\n status: 'failed',\n errors: [{ code, message }],\n };\n}\n\nfunction accountNotFoundMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n if (r.account_id) return `Unknown account_id: ${r.account_id}`;\n if (r.operator) return `Unknown operator: ${r.operator}`;\n return 'Unknown account reference';\n}\n\nfunction permissionDeniedMessage(ref: AccountReference): string {\n const r = narrowAccountRef(ref);\n const subject = r.operator ?? r.account_id ?? 'this account';\n return `Buyer agent has no authority over '${subject}' (tenant mismatch or auth principal not registered).`;\n}\n"],"mappings":"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","names":[]}
@@ -17,6 +17,8 @@
17
17
  * 3. Pin the connection to the validated IP — undici opens TCP/TLS to that
18
18
  * specific address, but the original hostname is preserved for TLS SNI
19
19
  * and the `Host:` header so HTTPS routing still works.
20
+ * 4. Never follow redirects. Steps 1-3 only ever see the URL the caller
21
+ * passed, so a followed `Location:` hop would bypass all of them.
20
22
  *
21
23
  * Implementation note: undici's `Agent` accepts a `connect.lookup` callback
22
24
  * with the same signature as `dns.lookup`. We hook the callback, resolve via
@@ -17,6 +17,8 @@
17
17
  * 3. Pin the connection to the validated IP — undici opens TCP/TLS to that
18
18
  * specific address, but the original hostname is preserved for TLS SNI
19
19
  * and the `Host:` header so HTTPS routing still works.
20
+ * 4. Never follow redirects. Steps 1-3 only ever see the URL the caller
21
+ * passed, so a followed `Location:` hop would bypass all of them.
20
22
  *
21
23
  * Implementation note: undici's `Agent` accepts a `connect.lookup` callback
22
24
  * with the same signature as `dns.lookup`. We hook the callback, resolve via
@@ -1 +1 @@
1
- {"version":3,"file":"pin-and-bind-fetch.d.ts","sourceRoot":"","sources":["../../../src/lib/server/pin-and-bind-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAE,KAAK,EAAyC,MAAM,QAAQ,CAAC;AACtE,OAAO,EAA6B,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAIzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,EAAE,UA+BlB,CAAC;AAEjB;;;;;;;;GAQG;AACH,eAAO,MAAM,+BAA+B,EAAE,UA8B9B,CAAC;AAEjB;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,CACzB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3E,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,KAC9E,IAAI,CAAC;AAEV,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;OAGG;IACH,YAAY,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG;QAC9C,OAAO,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;KACjE,CAAC;CACH;AAQD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,GAAG,OAAO,KAAK,CA+ExF"}
1
+ {"version":3,"file":"pin-and-bind-fetch.d.ts","sourceRoot":"","sources":["../../../src/lib/server/pin-and-bind-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAE,KAAK,EAAyC,MAAM,QAAQ,CAAC;AACtE,OAAO,EAA6B,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAIzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,EAAE,UA+BlB,CAAC;AAEjB;;;;;;;;GAQG;AACH,eAAO,MAAM,+BAA+B,EAAE,UA8B9B,CAAC;AAEjB;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,CACzB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3E,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,KAC9E,IAAI,CAAC;AAEV,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;OAGG;IACH,YAAY,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG;QAC9C,OAAO,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;KACjE,CAAC;CACH;AAQD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,GAAG,OAAO,KAAK,CAiGxF"}
@@ -142,8 +142,10 @@ function createPinAndBindFetch(options = {}) {
142
142
  throw makeSsrfError(sync.message ?? "SSRF policy denied URL", sync.rule ?? "ssrf");
143
143
  }
144
144
  }
145
+ const redirect = init?.redirect === "error" ? "error" : "manual";
145
146
  return (0, import_undici.fetch)(input, {
146
147
  ...init,
148
+ redirect,
147
149
  dispatcher
148
150
  });
149
151
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/server/pin-and-bind-fetch.ts"],"sourcesContent":["/**\n * Pin-and-bind fetch — DNS-rebinding-resistant `fetch` for outbound webhook\n * delivery and other callbacks where the destination is buyer-supplied.\n *\n * The problem: validating a `push_notification_config.url`'s LITERAL hostname\n * against an SSRF deny list does not protect against a DNS-rebinding attack.\n * A buyer can register `https://rebind.attacker.com/`, pass the literal-host\n * check, and then flip the A-record TTL to `169.254.169.254` (cloud metadata)\n * or `127.0.0.1` (loopback) before the webhook fires. Node's `fetch` resolves\n * the host fresh at request time, gets the rebound IP, and posts the\n * signed payload to the attacker-controlled destination.\n *\n * The fix: control DNS resolution and connect-target inside the fetch itself.\n * 1. Resolve the hostname.\n * 2. Validate every resolved IP against the SSRF policy (CIDR deny lists,\n * metadata-host names, scheme rules).\n * 3. Pin the connection to the validated IP — undici opens TCP/TLS to that\n * specific address, but the original hostname is preserved for TLS SNI\n * and the `Host:` header so HTTPS routing still works.\n *\n * Implementation note: undici's `Agent` accepts a `connect.lookup` callback\n * with the same signature as `dns.lookup`. We hook the callback, resolve via\n * `dns.lookup({ all: true })` to see EVERY address the host can reach, run\n * the resolved IPs through {@link enforceSsrfPolicyResolved}, and only return\n * a pinned address when every resolved IP is in an allowed range. This\n * matches the AdCP substitution-observer SSRF contract — a host that\n * resolves to *any* denied IP is treated as suspect and rejected wholesale.\n */\n\nimport { Agent, fetch as undiciFetch, type Dispatcher } from 'undici';\nimport { lookup as nativeDnsLookup, type LookupAddress } from 'node:dns';\nimport { isIPv6 } from 'node:net';\n\nimport { enforceSsrfPolicy, enforceSsrfPolicyResolved } from '../substitution/observer/ssrf';\nimport type { SsrfPolicy } from '../substitution/types';\n\n/**\n * Default SSRF policy for outbound webhook delivery. Stricter than\n * `DEFAULT_SSRF_POLICY` (substitution observer) only in that it allows\n * `host_literal_policy: 'allow'` — webhook URLs MAY use IP literals\n * (e.g. `https://203.0.113.10/cb`) as long as the IP is not in a denied\n * CIDR range. Schemes restricted to https; signed webhooks SHOULD be\n * delivered over TLS.\n *\n * For storyboard / in-process tests where the receiver runs on\n * `http://127.0.0.1:port`, use {@link LOOPBACK_OK_WEBHOOK_SSRF_POLICY}\n * instead. That preset relaxes only the loopback + http rules and keeps\n * every other deny range — adopters get most of the SSRF protection\n * during tests without disabling pin-and-bind entirely.\n */\nexport const WEBHOOK_SSRF_POLICY: SsrfPolicy = Object.freeze({\n schemes_allowed: Object.freeze(['https']),\n schemes_denied: Object.freeze(['http', 'file', 'gopher', 'ftp', 'ftps', 'data', 'javascript', 'about', 'ws', 'wss']),\n hosts_denied_ipv4_cidrs: Object.freeze([\n '0.0.0.0/8',\n '10.0.0.0/8',\n '100.64.0.0/10',\n '127.0.0.0/8',\n '169.254.0.0/16',\n '172.16.0.0/12',\n '192.0.0.0/24',\n '192.168.0.0/16',\n '224.0.0.0/4',\n '240.0.0.0/4',\n ]),\n hosts_denied_ipv6_cidrs: Object.freeze([\n '::1/128',\n '::/128',\n '::ffff:0:0/96',\n '64:ff9b::/96',\n 'fc00::/7',\n 'fe80::/10',\n 'ff00::/8',\n ]),\n hosts_denied_metadata: Object.freeze([\n 'metadata.google.internal',\n 'metadata',\n 'metadata.packet.net',\n 'fd00:ec2::254',\n ]),\n host_literal_policy: 'allow',\n}) as SsrfPolicy;\n\n/**\n * Pin-and-bind policy that allows http URLs and IPv4/IPv6 loopback so\n * adopters can enable pin-and-bind for production webhook delivery while\n * keeping storyboard / in-process tests working — `createWebhookReceiver`\n * listens on `http://127.0.0.1:port`. Every other private CIDR, metadata\n * host, and link-local range is still denied, so loopback is the only\n * relaxation. Pass to `createPinAndBindFetch({ policy })` from your test\n * fixture or storyboard runner harness; do NOT use in production.\n */\nexport const LOOPBACK_OK_WEBHOOK_SSRF_POLICY: SsrfPolicy = Object.freeze({\n schemes_allowed: Object.freeze(['http', 'https']),\n schemes_denied: Object.freeze(['file', 'gopher', 'ftp', 'ftps', 'data', 'javascript', 'about', 'ws', 'wss']),\n hosts_denied_ipv4_cidrs: Object.freeze([\n '0.0.0.0/8',\n '10.0.0.0/8',\n '100.64.0.0/10',\n // 127.0.0.0/8 omitted — loopback allowed for tests.\n '169.254.0.0/16',\n '172.16.0.0/12',\n '192.0.0.0/24',\n '192.168.0.0/16',\n '224.0.0.0/4',\n '240.0.0.0/4',\n ]),\n hosts_denied_ipv6_cidrs: Object.freeze([\n // ::1/128 and ::/128 omitted — IPv6 loopback allowed for tests.\n '::ffff:0:0/96',\n '64:ff9b::/96',\n 'fc00::/7',\n 'fe80::/10',\n 'ff00::/8',\n ]),\n hosts_denied_metadata: Object.freeze([\n 'metadata.google.internal',\n 'metadata',\n 'metadata.packet.net',\n 'fd00:ec2::254',\n ]),\n host_literal_policy: 'allow',\n}) as SsrfPolicy;\n\n/**\n * Signature of the lookup callback `dns.lookup` accepts. Re-declared here\n * because the native type from `node:dns` is a complex overload set; the\n * shape we actually need is the all=true variant the Agent expects.\n */\nexport type DnsLookupAll = (\n hostname: string,\n options: { family?: number; hints?: number; all: true; verbatim?: boolean },\n callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void\n) => void;\n\nexport interface PinAndBindFetchOptions {\n /**\n * SSRF policy to enforce against resolved IPs. Defaults to\n * {@link WEBHOOK_SSRF_POLICY} (https-only, all private/loopback/metadata\n * ranges denied, IP literals allowed).\n */\n policy?: SsrfPolicy;\n /**\n * Override the underlying DNS lookup. Default uses `dns.lookup` with\n * `all: true`. Tests inject a stub to simulate rebinding attacks without\n * touching real DNS.\n */\n lookup?: DnsLookupAll;\n /**\n * Forward to `Agent` — connect-attempt timeout, TLS options, etc. Cannot\n * override `lookup`; that is wired by this helper.\n */\n agentOptions?: Omit<Agent.Options, 'connect'> & {\n connect?: Omit<NonNullable<Agent.Options['connect']>, 'lookup'>;\n };\n}\n\nconst DEFAULT_LOOKUP_ALL: DnsLookupAll = (hostname, options, callback) => {\n nativeDnsLookup(hostname, { ...options, all: true }, (err, addresses) => {\n callback(err, addresses as LookupAddress[]);\n });\n};\n\n/**\n * Build a `fetch` that pins outbound connections to the IPs the SSRF policy\n * allows, defeating DNS-rebinding attacks against per-attempt DNS resolution.\n *\n * This is the default `fetch` for `createWebhookEmitter` /\n * `createAdcpServer({ webhooks })`, so production webhook delivery is\n * rebinding-protected without extra wiring. Call it explicitly only to\n * override the policy — e.g. {@link LOOPBACK_OK_WEBHOOK_SSRF_POLICY} for\n * storyboard tests that deliver to a loopback http receiver. See\n * `docs/guides/SIGNING-GUIDE.md` § Webhook SSRF defense.\n *\n * Construct once per emitter and reuse — each call instantiates a fresh\n * `undici.Agent` with its own connection pool.\n *\n * @example\n * ```ts\n * import { createWebhookEmitter, createPinAndBindFetch } from '@adcp/sdk/server';\n *\n * const emitter = createWebhookEmitter({\n * signerKey: webhookKey,\n * fetch: createPinAndBindFetch(),\n * });\n * ```\n */\nexport function createPinAndBindFetch(options: PinAndBindFetchOptions = {}): typeof fetch {\n const policy = options.policy ?? WEBHOOK_SSRF_POLICY;\n const lookupImpl = options.lookup ?? DEFAULT_LOOKUP_ALL;\n\n const guardedLookup = (\n hostname: string,\n opts: { family?: number; hints?: number; all?: boolean; verbatim?: boolean } | undefined,\n callback: (err: NodeJS.ErrnoException | null, addressOrAll?: string | LookupAddress[], family?: number) => void\n ): void => {\n const wantsAll = opts?.all === true;\n lookupImpl(hostname, { ...(opts ?? {}), all: true }, (err, addresses) => {\n if (err) {\n callback(err);\n return;\n }\n if (!Array.isArray(addresses) || addresses.length === 0) {\n callback(\n makeSsrfError(`DNS resolution returned no addresses for ${hostname}`, 'dns_revalidation:no_addresses')\n );\n return;\n }\n\n // The URL passed in here only matters for scheme + hostname checks,\n // both of which were already validated synchronously by undici when\n // the request started. We re-build a placeholder URL to feed the\n // resolved-address rule, which is the load-bearing check for\n // rebinding defense.\n const url = new URL(`https://${bracketIfV6(hostname)}`);\n const ips = addresses.map(a => a.address);\n const result = enforceSsrfPolicyResolved(url, ips, policy);\n if (!result.allowed) {\n callback(makeSsrfError(result.message ?? 'SSRF policy denied resolved address', result.rule ?? 'ssrf'));\n return;\n }\n\n if (wantsAll) {\n callback(null, addresses);\n return;\n }\n // Pin to the first resolved address. enforceSsrfPolicyResolved is\n // all-or-none: if it allowed the resolution, every entry passed.\n const first = addresses[0]!;\n callback(null, first.address, first.family);\n });\n };\n\n const dispatcher = new Agent({\n ...(options.agentOptions ?? {}),\n connect: {\n ...(options.agentOptions?.connect ?? {}),\n // undici's connect type accepts a lookup with the dns.lookup signature.\n lookup: guardedLookup as unknown as Agent.Options['connect'] extends infer T\n ? T extends { lookup?: infer L }\n ? L\n : never\n : never,\n },\n });\n\n const wrapped = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {\n // Synchronous pre-check for the URL's literal scheme + (if it's an IP)\n // its CIDR membership. undici skips `connect.lookup` for IP-literal\n // hostnames, so the resolved-IP path below would never see them.\n // This pre-check enforces the same SSRF policy on URLs like\n // `https://127.0.0.1/cb` or `https://[::1]/cb`.\n const url = resolveRequestUrl(input);\n if (url) {\n const sync = enforceSsrfPolicy(url, policy);\n if (!sync.allowed) {\n throw makeSsrfError(sync.message ?? 'SSRF policy denied URL', sync.rule ?? 'ssrf');\n }\n }\n return undiciFetch(input as Parameters<typeof undiciFetch>[0], {\n ...(init as Parameters<typeof undiciFetch>[1]),\n dispatcher: dispatcher as unknown as Dispatcher,\n }) as unknown as Response;\n };\n\n return wrapped as typeof fetch;\n}\n\nfunction resolveRequestUrl(input: Parameters<typeof fetch>[0]): URL | null {\n try {\n if (typeof input === 'string') return new URL(input);\n if (input instanceof URL) return input;\n if (\n typeof input === 'object' &&\n input !== null &&\n 'url' in input &&\n typeof (input as { url: unknown }).url === 'string'\n ) {\n return new URL((input as { url: string }).url);\n }\n } catch {\n // Let undici surface the parse error in its own shape.\n return null;\n }\n return null;\n}\n\nfunction bracketIfV6(host: string): string {\n return isIPv6(host) ? `[${host}]` : host;\n}\n\nfunction makeSsrfError(message: string, rule: string): NodeJS.ErrnoException {\n const err = new Error(`pin-and-bind: ${rule}: ${message}`) as NodeJS.ErrnoException;\n err.code = 'EADCP_SSRF_BLOCKED';\n return err;\n}\n"],"mappings":"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","names":["nativeDnsLookup","undiciFetch"]}
1
+ {"version":3,"sources":["../../../src/lib/server/pin-and-bind-fetch.ts"],"sourcesContent":["/**\n * Pin-and-bind fetch — DNS-rebinding-resistant `fetch` for outbound webhook\n * delivery and other callbacks where the destination is buyer-supplied.\n *\n * The problem: validating a `push_notification_config.url`'s LITERAL hostname\n * against an SSRF deny list does not protect against a DNS-rebinding attack.\n * A buyer can register `https://rebind.attacker.com/`, pass the literal-host\n * check, and then flip the A-record TTL to `169.254.169.254` (cloud metadata)\n * or `127.0.0.1` (loopback) before the webhook fires. Node's `fetch` resolves\n * the host fresh at request time, gets the rebound IP, and posts the\n * signed payload to the attacker-controlled destination.\n *\n * The fix: control DNS resolution and connect-target inside the fetch itself.\n * 1. Resolve the hostname.\n * 2. Validate every resolved IP against the SSRF policy (CIDR deny lists,\n * metadata-host names, scheme rules).\n * 3. Pin the connection to the validated IP — undici opens TCP/TLS to that\n * specific address, but the original hostname is preserved for TLS SNI\n * and the `Host:` header so HTTPS routing still works.\n * 4. Never follow redirects. Steps 1-3 only ever see the URL the caller\n * passed, so a followed `Location:` hop would bypass all of them.\n *\n * Implementation note: undici's `Agent` accepts a `connect.lookup` callback\n * with the same signature as `dns.lookup`. We hook the callback, resolve via\n * `dns.lookup({ all: true })` to see EVERY address the host can reach, run\n * the resolved IPs through {@link enforceSsrfPolicyResolved}, and only return\n * a pinned address when every resolved IP is in an allowed range. This\n * matches the AdCP substitution-observer SSRF contract — a host that\n * resolves to *any* denied IP is treated as suspect and rejected wholesale.\n */\n\nimport { Agent, fetch as undiciFetch, type Dispatcher } from 'undici';\nimport { lookup as nativeDnsLookup, type LookupAddress } from 'node:dns';\nimport { isIPv6 } from 'node:net';\n\nimport { enforceSsrfPolicy, enforceSsrfPolicyResolved } from '../substitution/observer/ssrf';\nimport type { SsrfPolicy } from '../substitution/types';\n\n/**\n * Default SSRF policy for outbound webhook delivery. Stricter than\n * `DEFAULT_SSRF_POLICY` (substitution observer) only in that it allows\n * `host_literal_policy: 'allow'` — webhook URLs MAY use IP literals\n * (e.g. `https://203.0.113.10/cb`) as long as the IP is not in a denied\n * CIDR range. Schemes restricted to https; signed webhooks SHOULD be\n * delivered over TLS.\n *\n * For storyboard / in-process tests where the receiver runs on\n * `http://127.0.0.1:port`, use {@link LOOPBACK_OK_WEBHOOK_SSRF_POLICY}\n * instead. That preset relaxes only the loopback + http rules and keeps\n * every other deny range — adopters get most of the SSRF protection\n * during tests without disabling pin-and-bind entirely.\n */\nexport const WEBHOOK_SSRF_POLICY: SsrfPolicy = Object.freeze({\n schemes_allowed: Object.freeze(['https']),\n schemes_denied: Object.freeze(['http', 'file', 'gopher', 'ftp', 'ftps', 'data', 'javascript', 'about', 'ws', 'wss']),\n hosts_denied_ipv4_cidrs: Object.freeze([\n '0.0.0.0/8',\n '10.0.0.0/8',\n '100.64.0.0/10',\n '127.0.0.0/8',\n '169.254.0.0/16',\n '172.16.0.0/12',\n '192.0.0.0/24',\n '192.168.0.0/16',\n '224.0.0.0/4',\n '240.0.0.0/4',\n ]),\n hosts_denied_ipv6_cidrs: Object.freeze([\n '::1/128',\n '::/128',\n '::ffff:0:0/96',\n '64:ff9b::/96',\n 'fc00::/7',\n 'fe80::/10',\n 'ff00::/8',\n ]),\n hosts_denied_metadata: Object.freeze([\n 'metadata.google.internal',\n 'metadata',\n 'metadata.packet.net',\n 'fd00:ec2::254',\n ]),\n host_literal_policy: 'allow',\n}) as SsrfPolicy;\n\n/**\n * Pin-and-bind policy that allows http URLs and IPv4/IPv6 loopback so\n * adopters can enable pin-and-bind for production webhook delivery while\n * keeping storyboard / in-process tests working — `createWebhookReceiver`\n * listens on `http://127.0.0.1:port`. Every other private CIDR, metadata\n * host, and link-local range is still denied, so loopback is the only\n * relaxation. Pass to `createPinAndBindFetch({ policy })` from your test\n * fixture or storyboard runner harness; do NOT use in production.\n */\nexport const LOOPBACK_OK_WEBHOOK_SSRF_POLICY: SsrfPolicy = Object.freeze({\n schemes_allowed: Object.freeze(['http', 'https']),\n schemes_denied: Object.freeze(['file', 'gopher', 'ftp', 'ftps', 'data', 'javascript', 'about', 'ws', 'wss']),\n hosts_denied_ipv4_cidrs: Object.freeze([\n '0.0.0.0/8',\n '10.0.0.0/8',\n '100.64.0.0/10',\n // 127.0.0.0/8 omitted — loopback allowed for tests.\n '169.254.0.0/16',\n '172.16.0.0/12',\n '192.0.0.0/24',\n '192.168.0.0/16',\n '224.0.0.0/4',\n '240.0.0.0/4',\n ]),\n hosts_denied_ipv6_cidrs: Object.freeze([\n // ::1/128 and ::/128 omitted — IPv6 loopback allowed for tests.\n '::ffff:0:0/96',\n '64:ff9b::/96',\n 'fc00::/7',\n 'fe80::/10',\n 'ff00::/8',\n ]),\n hosts_denied_metadata: Object.freeze([\n 'metadata.google.internal',\n 'metadata',\n 'metadata.packet.net',\n 'fd00:ec2::254',\n ]),\n host_literal_policy: 'allow',\n}) as SsrfPolicy;\n\n/**\n * Signature of the lookup callback `dns.lookup` accepts. Re-declared here\n * because the native type from `node:dns` is a complex overload set; the\n * shape we actually need is the all=true variant the Agent expects.\n */\nexport type DnsLookupAll = (\n hostname: string,\n options: { family?: number; hints?: number; all: true; verbatim?: boolean },\n callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void\n) => void;\n\nexport interface PinAndBindFetchOptions {\n /**\n * SSRF policy to enforce against resolved IPs. Defaults to\n * {@link WEBHOOK_SSRF_POLICY} (https-only, all private/loopback/metadata\n * ranges denied, IP literals allowed).\n */\n policy?: SsrfPolicy;\n /**\n * Override the underlying DNS lookup. Default uses `dns.lookup` with\n * `all: true`. Tests inject a stub to simulate rebinding attacks without\n * touching real DNS.\n */\n lookup?: DnsLookupAll;\n /**\n * Forward to `Agent` — connect-attempt timeout, TLS options, etc. Cannot\n * override `lookup`; that is wired by this helper.\n */\n agentOptions?: Omit<Agent.Options, 'connect'> & {\n connect?: Omit<NonNullable<Agent.Options['connect']>, 'lookup'>;\n };\n}\n\nconst DEFAULT_LOOKUP_ALL: DnsLookupAll = (hostname, options, callback) => {\n nativeDnsLookup(hostname, { ...options, all: true }, (err, addresses) => {\n callback(err, addresses as LookupAddress[]);\n });\n};\n\n/**\n * Build a `fetch` that pins outbound connections to the IPs the SSRF policy\n * allows, defeating DNS-rebinding attacks against per-attempt DNS resolution.\n *\n * This is the default `fetch` for `createWebhookEmitter` /\n * `createAdcpServer({ webhooks })`, so production webhook delivery is\n * rebinding-protected without extra wiring. Call it explicitly only to\n * override the policy — e.g. {@link LOOPBACK_OK_WEBHOOK_SSRF_POLICY} for\n * storyboard tests that deliver to a loopback http receiver. See\n * `docs/guides/SIGNING-GUIDE.md` § Webhook SSRF defense.\n *\n * Construct once per emitter and reuse — each call instantiates a fresh\n * `undici.Agent` with its own connection pool.\n *\n * @example\n * ```ts\n * import { createWebhookEmitter, createPinAndBindFetch } from '@adcp/sdk/server';\n *\n * const emitter = createWebhookEmitter({\n * signerKey: webhookKey,\n * fetch: createPinAndBindFetch(),\n * });\n * ```\n */\nexport function createPinAndBindFetch(options: PinAndBindFetchOptions = {}): typeof fetch {\n const policy = options.policy ?? WEBHOOK_SSRF_POLICY;\n const lookupImpl = options.lookup ?? DEFAULT_LOOKUP_ALL;\n\n const guardedLookup = (\n hostname: string,\n opts: { family?: number; hints?: number; all?: boolean; verbatim?: boolean } | undefined,\n callback: (err: NodeJS.ErrnoException | null, addressOrAll?: string | LookupAddress[], family?: number) => void\n ): void => {\n const wantsAll = opts?.all === true;\n lookupImpl(hostname, { ...(opts ?? {}), all: true }, (err, addresses) => {\n if (err) {\n callback(err);\n return;\n }\n if (!Array.isArray(addresses) || addresses.length === 0) {\n callback(\n makeSsrfError(`DNS resolution returned no addresses for ${hostname}`, 'dns_revalidation:no_addresses')\n );\n return;\n }\n\n // The URL passed in here only matters for scheme + hostname checks,\n // both of which were already validated synchronously by undici when\n // the request started. We re-build a placeholder URL to feed the\n // resolved-address rule, which is the load-bearing check for\n // rebinding defense.\n const url = new URL(`https://${bracketIfV6(hostname)}`);\n const ips = addresses.map(a => a.address);\n const result = enforceSsrfPolicyResolved(url, ips, policy);\n if (!result.allowed) {\n callback(makeSsrfError(result.message ?? 'SSRF policy denied resolved address', result.rule ?? 'ssrf'));\n return;\n }\n\n if (wantsAll) {\n callback(null, addresses);\n return;\n }\n // Pin to the first resolved address. enforceSsrfPolicyResolved is\n // all-or-none: if it allowed the resolution, every entry passed.\n const first = addresses[0]!;\n callback(null, first.address, first.family);\n });\n };\n\n const dispatcher = new Agent({\n ...(options.agentOptions ?? {}),\n connect: {\n ...(options.agentOptions?.connect ?? {}),\n // undici's connect type accepts a lookup with the dns.lookup signature.\n lookup: guardedLookup as unknown as Agent.Options['connect'] extends infer T\n ? T extends { lookup?: infer L }\n ? L\n : never\n : never,\n },\n });\n\n const wrapped = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {\n // Synchronous pre-check for the URL's literal scheme + (if it's an IP)\n // its CIDR membership. undici skips `connect.lookup` for IP-literal\n // hostnames, so the resolved-IP path below would never see them.\n // This pre-check enforces the same SSRF policy on URLs like\n // `https://127.0.0.1/cb` or `https://[::1]/cb`.\n const url = resolveRequestUrl(input);\n if (url) {\n const sync = enforceSsrfPolicy(url, policy);\n if (!sync.allowed) {\n throw makeSsrfError(sync.message ?? 'SSRF policy denied URL', sync.rule ?? 'ssrf');\n }\n }\n // Redirects are never followed, and this is not caller-overridable.\n //\n // Both guards above only ever see the URL the caller passed. Following a\n // redirect would send the request to a destination neither one evaluated:\n // the synchronous check has already run, and undici skips `connect.lookup`\n // for IP-literal hosts, so a `Location: https://169.254.169.254/` hop\n // reaches the metadata service with the payload attached. Honouring a\n // caller's `redirect: 'follow'` would reopen exactly that hole, so the\n // mode is forced rather than defaulted.\n //\n // A 3xx therefore surfaces to the caller as an ordinary non-2xx response.\n // For signed webhook delivery that is also the correct outcome on its own\n // terms — the signature covers `@target-uri`, so a request replayed at a\n // redirect target would not verify there anyway. Callers that genuinely\n // need to follow a hop should re-enter this fetch with the new URL, which\n // re-runs the full policy on it.\n const redirect = (init as { redirect?: string } | undefined)?.redirect === 'error' ? 'error' : 'manual';\n return undiciFetch(input as Parameters<typeof undiciFetch>[0], {\n ...(init as Parameters<typeof undiciFetch>[1]),\n redirect,\n dispatcher: dispatcher as unknown as Dispatcher,\n }) as unknown as Response;\n };\n\n return wrapped as typeof fetch;\n}\n\nfunction resolveRequestUrl(input: Parameters<typeof fetch>[0]): URL | null {\n try {\n if (typeof input === 'string') return new URL(input);\n if (input instanceof URL) return input;\n if (\n typeof input === 'object' &&\n input !== null &&\n 'url' in input &&\n typeof (input as { url: unknown }).url === 'string'\n ) {\n return new URL((input as { url: string }).url);\n }\n } catch {\n // Let undici surface the parse error in its own shape.\n return null;\n }\n return null;\n}\n\nfunction bracketIfV6(host: string): string {\n return isIPv6(host) ? `[${host}]` : host;\n}\n\nfunction makeSsrfError(message: string, rule: string): NodeJS.ErrnoException {\n const err = new Error(`pin-and-bind: ${rule}: ${message}`) as NodeJS.ErrnoException;\n err.code = 'EADCP_SSRF_BLOCKED';\n return err;\n}\n"],"mappings":"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","names":["nativeDnsLookup","undiciFetch"]}
@@ -117,8 +117,10 @@ function createPinAndBindFetch(options = {}) {
117
117
  throw makeSsrfError(sync.message ?? "SSRF policy denied URL", sync.rule ?? "ssrf");
118
118
  }
119
119
  }
120
+ const redirect = init?.redirect === "error" ? "error" : "manual";
120
121
  return undiciFetch(input, {
121
122
  ...init,
123
+ redirect,
122
124
  dispatcher
123
125
  });
124
126
  };