@adcp/sdk 14.0.0-beta.19 → 14.0.0-beta.20

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 (36) hide show
  1. package/bin/adcp.js +154 -6
  2. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  3. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.d.mts +10 -0
  4. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.d.ts +10 -0
  5. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +21 -0
  6. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +21 -0
  7. package/dist/lib/server/serve.d.mts +11 -0
  8. package/dist/lib/server/serve.d.ts +11 -0
  9. package/dist/lib/server/serve.js +35 -5
  10. package/dist/lib/server/serve.mjs +35 -5
  11. package/dist/lib/signing/brand-jwks.d.mts +44 -1
  12. package/dist/lib/signing/brand-jwks.d.ts +44 -1
  13. package/dist/lib/signing/brand-jwks.js +53 -15
  14. package/dist/lib/signing/brand-jwks.mjs +51 -14
  15. package/dist/lib/signing/server.d.mts +1 -1
  16. package/dist/lib/signing/server.d.ts +1 -1
  17. package/dist/lib/signing/server.js +2 -0
  18. package/dist/lib/signing/server.mjs +3 -1
  19. package/dist/lib/testing/storyboard/runner.js +6 -2
  20. package/dist/lib/testing/storyboard/runner.mjs +6 -2
  21. package/dist/lib/testing/storyboard/types.d.mts +6 -0
  22. package/dist/lib/testing/storyboard/types.d.ts +6 -0
  23. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  24. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  25. package/dist/lib/testing/storyboard/webhook-assertions.js +6 -1
  26. package/dist/lib/testing/storyboard/webhook-assertions.mjs +6 -1
  27. package/dist/lib/testing/storyboard/webhook-receiver.d.mts +15 -0
  28. package/dist/lib/testing/storyboard/webhook-receiver.d.ts +15 -0
  29. package/dist/lib/testing/storyboard/webhook-receiver.js +55 -15
  30. package/dist/lib/testing/storyboard/webhook-receiver.mjs +55 -15
  31. package/dist/lib/version.d.mts +3 -3
  32. package/dist/lib/version.d.ts +3 -3
  33. package/dist/lib/version.js +3 -3
  34. package/dist/lib/version.mjs +3 -3
  35. package/docs/llms.txt +1 -1
  36. package/package.json +1 -1
package/bin/adcp.js CHANGED
@@ -23,7 +23,7 @@ if (process.argv.includes('--allow-http')) {
23
23
  }
24
24
 
25
25
  const { AdCPClient, detectProtocol, usesDeprecatedAssetsField } = require('../dist/lib/index.js');
26
- const { readFileSync, statSync } = require('fs');
26
+ const { readFileSync, statSync, openSync, fstatSync, readSync, closeSync, constants: fsConstants } = require('fs');
27
27
  const path = require('path');
28
28
  const { pathToFileURL } = require('url');
29
29
  const net = require('net');
@@ -1130,6 +1130,13 @@ function parseAgentOptions(args) {
1130
1130
  !args[webhookReceiverPortIdx + 1].startsWith('--')
1131
1131
  ? args[webhookReceiverPortIdx + 1]
1132
1132
  : null;
1133
+ const webhookReceiverHostIdx = args.indexOf('--webhook-receiver-host');
1134
+ const webhookReceiverHostValue =
1135
+ webhookReceiverHostIdx !== -1 &&
1136
+ webhookReceiverHostIdx + 1 < args.length &&
1137
+ !args[webhookReceiverHostIdx + 1].startsWith('--')
1138
+ ? args[webhookReceiverHostIdx + 1]
1139
+ : null;
1133
1140
  const webhookReceiverPublicUrlIdx = args.indexOf('--webhook-receiver-public-url');
1134
1141
  const webhookReceiverPublicUrlValue =
1135
1142
  webhookReceiverPublicUrlIdx !== -1 &&
@@ -1137,6 +1144,20 @@ function parseAgentOptions(args) {
1137
1144
  !args[webhookReceiverPublicUrlIdx + 1].startsWith('--')
1138
1145
  ? args[webhookReceiverPublicUrlIdx + 1]
1139
1146
  : null;
1147
+ const webhookReceiverTlsCertIdx = args.indexOf('--webhook-receiver-tls-cert');
1148
+ const webhookReceiverTlsCertValue =
1149
+ webhookReceiverTlsCertIdx !== -1 &&
1150
+ webhookReceiverTlsCertIdx + 1 < args.length &&
1151
+ !args[webhookReceiverTlsCertIdx + 1].startsWith('--')
1152
+ ? args[webhookReceiverTlsCertIdx + 1]
1153
+ : null;
1154
+ const webhookReceiverTlsKeyIdx = args.indexOf('--webhook-receiver-tls-key');
1155
+ const webhookReceiverTlsKeyValue =
1156
+ webhookReceiverTlsKeyIdx !== -1 &&
1157
+ webhookReceiverTlsKeyIdx + 1 < args.length &&
1158
+ !args[webhookReceiverTlsKeyIdx + 1].startsWith('--')
1159
+ ? args[webhookReceiverTlsKeyIdx + 1]
1160
+ : null;
1140
1161
 
1141
1162
  const invariantsIdx = args.indexOf('--invariants');
1142
1163
  const invariantsValue =
@@ -1212,7 +1233,10 @@ function parseAgentOptions(args) {
1212
1233
  multiInstanceStrategyValue,
1213
1234
  webhookReceiverModeValue,
1214
1235
  webhookReceiverPortValue,
1236
+ webhookReceiverHostValue,
1215
1237
  webhookReceiverPublicUrlValue,
1238
+ webhookReceiverTlsCertValue,
1239
+ webhookReceiverTlsKeyValue,
1216
1240
  invariantsValue,
1217
1241
  localAgentValue,
1218
1242
  formatValue,
@@ -2111,11 +2135,19 @@ WEBHOOK OPTIONS:
2111
2135
  the webhook-emission and idempotency bundles
2112
2136
  to produce grades instead of skips.
2113
2137
  --webhook-receiver-port PORT Force a bind port (default: auto-assign).
2138
+ --webhook-receiver-host HOST Bind address for the local listener (default:
2139
+ 127.0.0.1). Use 0.0.0.0 or :: with proxy mode
2140
+ for container-to-container callbacks.
2114
2141
  --webhook-receiver-public-url URL
2115
- Public HTTPS base URL for proxy mode. Implies
2142
+ Public HTTPS base URL for proxy mode. HTTP is
2143
+ accepted only with --allow-http. Implies
2116
2144
  --webhook-receiver proxy when used alone.
2117
2145
  Incompatible with --multi-instance-strategy
2118
2146
  multi-pass (receiver URL is per-pass).
2147
+ --webhook-receiver-tls-cert FILE
2148
+ --webhook-receiver-tls-key FILE Serve HTTPS directly with this certificate
2149
+ and private key. Both flags are required.
2150
+ Omit when a tunnel or ingress terminates TLS.
2119
2151
  --webhook-receiver-auto-tunnel Autodetect a tunnel binary on PATH (ngrok or
2120
2152
  cloudflared; override with $ADCP_WEBHOOK_TUNNEL),
2121
2153
  spawn it against the receiver, and plug its
@@ -2923,6 +2955,7 @@ async function handleStoryboardRun(args) {
2923
2955
  ...(fileComplianceOptions.adcpVersion && { adcpVersion: fileComplianceOptions.adcpVersion }),
2924
2956
  ...(fileComplianceOptions.schemaRoot && { schemaRoot: fileComplianceOptions.schemaRoot }),
2925
2957
  ...(!opts.strictResponseSchemaValidation && { strictResponseSchemaValidation: false }),
2958
+ ...(opts.allowHttp && { allow_http: true }),
2926
2959
  ...sandboxRunOptions(opts),
2927
2960
  ...(opts.assertsSeededState && { assertsSeededState: true }),
2928
2961
  ...(opts.mediaBuyLifecycleCompatibility && {
@@ -3031,8 +3064,8 @@ async function handleStoryboardRun(args) {
3031
3064
  * transport and land in attribution output.
3032
3065
  */
3033
3066
  /**
3034
- * Parse `--webhook-receiver [mode]`, `--webhook-receiver-port <port>`, and
3035
- * `--webhook-receiver-public-url <url>`. Returns a `{ webhook_receiver, contracts }`
3067
+ * Parse `--webhook-receiver [mode]` and its host, port, public-URL, and TLS
3068
+ * flags. Returns a `{ webhook_receiver, contracts }`
3036
3069
  * pair suitable for spreading into `runStoryboard` / `comply` options, or
3037
3070
  * `null` if no webhook-receiver flag is set.
3038
3071
  *
@@ -3046,8 +3079,13 @@ function extractWebhookReceiverOptions(args) {
3046
3079
  const idx = args.indexOf('--webhook-receiver');
3047
3080
  const publicUrlIdx = args.indexOf('--webhook-receiver-public-url');
3048
3081
  const portIdx = args.indexOf('--webhook-receiver-port');
3082
+ const hostIdx = args.indexOf('--webhook-receiver-host');
3083
+ const tlsCertIdx = args.indexOf('--webhook-receiver-tls-cert');
3084
+ const tlsKeyIdx = args.indexOf('--webhook-receiver-tls-key');
3049
3085
 
3050
- if (idx === -1 && publicUrlIdx === -1 && portIdx === -1) return null;
3086
+ if (idx === -1 && publicUrlIdx === -1 && portIdx === -1 && hostIdx === -1 && tlsCertIdx === -1 && tlsKeyIdx === -1) {
3087
+ return null;
3088
+ }
3051
3089
 
3052
3090
  let mode = 'loopback_mock';
3053
3091
  if (idx !== -1) {
@@ -3071,6 +3109,29 @@ function extractWebhookReceiverOptions(args) {
3071
3109
  process.exit(2);
3072
3110
  }
3073
3111
  publicUrl = val;
3112
+ let parsedPublicUrl;
3113
+ try {
3114
+ parsedPublicUrl = new URL(publicUrl);
3115
+ } catch {
3116
+ console.error(`ERROR: --webhook-receiver-public-url is not a valid URL: "${publicUrl}"`);
3117
+ process.exit(2);
3118
+ }
3119
+ if (parsedPublicUrl.protocol !== 'http:' && parsedPublicUrl.protocol !== 'https:') {
3120
+ console.error(`ERROR: --webhook-receiver-public-url must use http or https, got ${parsedPublicUrl.protocol}`);
3121
+ process.exit(2);
3122
+ }
3123
+ if (parsedPublicUrl.username || parsedPublicUrl.password) {
3124
+ console.error('ERROR: --webhook-receiver-public-url must not include userinfo');
3125
+ process.exit(2);
3126
+ }
3127
+ if (parsedPublicUrl.search || parsedPublicUrl.hash) {
3128
+ console.error('ERROR: --webhook-receiver-public-url must not include a query string or fragment');
3129
+ process.exit(2);
3130
+ }
3131
+ if (parsedPublicUrl.protocol === 'http:' && !args.includes('--allow-http')) {
3132
+ console.error('ERROR: an http:// webhook receiver public URL requires --allow-http (local development only)');
3133
+ process.exit(2);
3134
+ }
3074
3135
  // --webhook-receiver-public-url without --webhook-receiver implies proxy mode.
3075
3136
  // With explicit `--webhook-receiver loopback`, the combination is a user
3076
3137
  // error — loopback mode ignores public_url.
@@ -3105,16 +3166,93 @@ function extractWebhookReceiverOptions(args) {
3105
3166
  port = parsed;
3106
3167
  }
3107
3168
 
3169
+ let host;
3170
+ if (hostIdx !== -1) {
3171
+ host = args[hostIdx + 1];
3172
+ if (host === undefined || host.startsWith('--')) {
3173
+ console.error('ERROR: --webhook-receiver-host requires a hostname or bind address');
3174
+ process.exit(2);
3175
+ }
3176
+ if (host.length === 0 || /[\s/\\\r\n\x00]/.test(host)) {
3177
+ console.error(`ERROR: --webhook-receiver-host is not a valid bind address: "${host}"`);
3178
+ process.exit(2);
3179
+ }
3180
+ }
3181
+
3182
+ const tlsCertPath = tlsCertIdx === -1 ? undefined : args[tlsCertIdx + 1];
3183
+ const tlsKeyPath = tlsKeyIdx === -1 ? undefined : args[tlsKeyIdx + 1];
3184
+ if (tlsCertPath === undefined && tlsCertIdx !== -1) {
3185
+ console.error('ERROR: --webhook-receiver-tls-cert requires a file path');
3186
+ process.exit(2);
3187
+ }
3188
+ if (tlsKeyPath === undefined && tlsKeyIdx !== -1) {
3189
+ console.error('ERROR: --webhook-receiver-tls-key requires a file path');
3190
+ process.exit(2);
3191
+ }
3192
+ if (tlsCertPath?.startsWith('--')) {
3193
+ console.error('ERROR: --webhook-receiver-tls-cert requires a file path');
3194
+ process.exit(2);
3195
+ }
3196
+ if (tlsKeyPath?.startsWith('--')) {
3197
+ console.error('ERROR: --webhook-receiver-tls-key requires a file path');
3198
+ process.exit(2);
3199
+ }
3200
+ if ((tlsCertPath === undefined) !== (tlsKeyPath === undefined)) {
3201
+ console.error('ERROR: --webhook-receiver-tls-cert and --webhook-receiver-tls-key must be provided together');
3202
+ process.exit(2);
3203
+ }
3204
+ let tls;
3205
+ if (tlsCertPath !== undefined && tlsKeyPath !== undefined) {
3206
+ try {
3207
+ tls = {
3208
+ cert: readBoundedRegularFile(path.resolve(tlsCertPath), 'TLS certificate'),
3209
+ key: readBoundedRegularFile(path.resolve(tlsKeyPath), 'TLS private key'),
3210
+ };
3211
+ } catch (err) {
3212
+ console.error(`ERROR: unable to read webhook receiver TLS files: ${err.message}`);
3213
+ process.exit(2);
3214
+ }
3215
+ }
3216
+ if (tls && publicUrl && new URL(publicUrl).protocol !== 'https:') {
3217
+ console.error('ERROR: direct webhook receiver TLS requires an https:// public URL');
3218
+ process.exit(2);
3219
+ }
3220
+
3108
3221
  return {
3109
3222
  webhook_receiver: {
3110
3223
  mode,
3224
+ ...(host !== undefined && { host }),
3111
3225
  ...(port !== undefined && { port }),
3112
3226
  ...(publicUrl !== undefined && { public_url: publicUrl }),
3227
+ ...(tls !== undefined && { tls }),
3113
3228
  },
3114
3229
  contracts: ['webhook_receiver_runner'],
3115
3230
  };
3116
3231
  }
3117
3232
 
3233
+ const MAX_WEBHOOK_TLS_FILE_BYTES = 1_048_576;
3234
+
3235
+ function readBoundedRegularFile(filePath, label) {
3236
+ const fd = openSync(filePath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
3237
+ try {
3238
+ const stat = fstatSync(fd);
3239
+ if (!stat.isFile()) throw new Error(`${label} must be a regular file`);
3240
+ if (stat.size > MAX_WEBHOOK_TLS_FILE_BYTES) {
3241
+ throw new Error(`${label} exceeds the 1 MiB size limit`);
3242
+ }
3243
+ const buffer = Buffer.alloc(stat.size);
3244
+ let offset = 0;
3245
+ while (offset < buffer.length) {
3246
+ const count = readSync(fd, buffer, offset, buffer.length - offset, null);
3247
+ if (count === 0) break;
3248
+ offset += count;
3249
+ }
3250
+ return offset === buffer.length ? buffer : buffer.subarray(0, offset);
3251
+ } finally {
3252
+ closeSync(fd);
3253
+ }
3254
+ }
3255
+
3118
3256
  /**
3119
3257
  * Dynamic-import modules listed in --invariants so their `registerAssertion(...)`
3120
3258
  * calls populate the storyboard assertion registry before the runner resolves
@@ -3466,6 +3604,11 @@ function validateAutoTunnelArgs(args, base) {
3466
3604
  console.error(' Pick one — auto-tunnel mints a URL for you, public-url supplies your own.');
3467
3605
  process.exit(2);
3468
3606
  }
3607
+ if (base?.webhook_receiver.tls) {
3608
+ console.error('ERROR: --webhook-receiver-auto-tunnel cannot be combined with direct TLS certificate flags.');
3609
+ console.error(' The tunnel terminates HTTPS and forwards plain HTTP to the local receiver.');
3610
+ process.exit(2);
3611
+ }
3469
3612
  // Auto-tunnel implies proxy mode and mints the URL itself. A coexisting
3470
3613
  // `--webhook-receiver [mode]` flag is always wrong: `loopback` contradicts
3471
3614
  // the minted URL, `proxy` without public-url is caught earlier in
@@ -3488,7 +3631,12 @@ async function resolveWebhookReceiverOptions(args, { jsonOutput } = {}) {
3488
3631
  const { publicUrl } = await spawnAutoTunnel({ port, timeoutMs, jsonOutput });
3489
3632
 
3490
3633
  return {
3491
- webhook_receiver: { mode: 'proxy_url', port, public_url: publicUrl },
3634
+ webhook_receiver: {
3635
+ mode: 'proxy_url',
3636
+ ...(base?.webhook_receiver.host !== undefined && { host: base.webhook_receiver.host }),
3637
+ port,
3638
+ public_url: publicUrl,
3639
+ },
3492
3640
  contracts: ['webhook_receiver_runner'],
3493
3641
  };
3494
3642
  }
@@ -4,5 +4,5 @@
4
4
  "source_sha": "4e553ad955f83b49c7d221ab5c3ff78237ad02e3",
5
5
  "source_tarball_sha256": "580656d6466ef9f0d1119985e6726c2efea718dc671e2ad30957fcb2fd54af0f",
6
6
  "upstream_adcp_version": "2.5.3",
7
- "synced_at": "2026-08-30T04:26:50.358Z"
7
+ "synced_at": "2026-08-30T06:10:51.963Z"
8
8
  }
@@ -67,6 +67,16 @@ export interface PostgresTaskSettlementCoordinator {
67
67
  readonly durability: 'durable';
68
68
  /** Wire this into the normal webhook recovery poller/emitter. */
69
69
  readonly recovery: DurableWebhookDeliveryRecovery;
70
+ /**
71
+ * Prove that a scoped terminal task has its deterministic durable webhook
72
+ * checkpoint. This does not compare the task's result/error with a caller's
73
+ * intended artifact and does not prove delivery; verify terminal artifact
74
+ * compatibility separately before acknowledging an intent. Reconstructed
75
+ * coordinators must use the same registry, publisher scope, and outbox, and
76
+ * the checkpoint must be retained for the full intent replay horizon. The
77
+ * proof does not require the now-redacted push config.
78
+ */
79
+ hasTerminalCheckpoint(ref: ScopedTaskRef): Promise<boolean>;
70
80
  settle(ref: ScopedTaskRef, terminal: TerminalSettlement, push: TaskPushSettlementConfig): Promise<TaskPushSettlementOutcome>;
71
81
  }
72
82
  /** Configuration or immutable-delivery conflict detected before commit. */
@@ -67,6 +67,16 @@ export interface PostgresTaskSettlementCoordinator {
67
67
  readonly durability: 'durable';
68
68
  /** Wire this into the normal webhook recovery poller/emitter. */
69
69
  readonly recovery: DurableWebhookDeliveryRecovery;
70
+ /**
71
+ * Prove that a scoped terminal task has its deterministic durable webhook
72
+ * checkpoint. This does not compare the task's result/error with a caller's
73
+ * intended artifact and does not prove delivery; verify terminal artifact
74
+ * compatibility separately before acknowledging an intent. Reconstructed
75
+ * coordinators must use the same registry, publisher scope, and outbox, and
76
+ * the checkpoint must be retained for the full intent replay horizon. The
77
+ * proof does not require the now-redacted push config.
78
+ */
79
+ hasTerminalCheckpoint(ref: ScopedTaskRef): Promise<boolean>;
70
80
  settle(ref: ScopedTaskRef, terminal: TerminalSettlement, push: TaskPushSettlementConfig): Promise<TaskPushSettlementOutcome>;
71
81
  }
72
82
  /** Configuration or immutable-delivery conflict detected before commit. */
@@ -77,6 +77,27 @@ function createPostgresTaskSettlementCoordinator(options) {
77
77
  return {
78
78
  durability: "durable",
79
79
  recovery,
80
+ async hasTerminalCheckpoint(ref) {
81
+ const settlementRef = snapshotSettlementRef(ref);
82
+ if (settlementRef.registryId !== registryId) return false;
83
+ const deliveryKey = {
84
+ ...claimScope,
85
+ deliveryId: stableScope("task-webhook", settlementRef)
86
+ };
87
+ try {
88
+ const task = await readTaskRow(
89
+ pool,
90
+ binding.tableName,
91
+ binding.namespace,
92
+ settlementRef,
93
+ false
94
+ );
95
+ if (!task || !TERMINAL.has(task.status) || task.has_webhook !== true) return false;
96
+ return Boolean(await readOutbox(pool, outboxTable, deliveryKey, false));
97
+ } catch (cause) {
98
+ throw new Error("PostgresTaskSettlementCoordinator.hasTerminalCheckpoint: query failed", { cause });
99
+ }
100
+ },
80
101
  async settle(ref, terminal, push) {
81
102
  const settlementRef = snapshotSettlementRef(ref);
82
103
  if (settlementRef.registryId !== registryId) {
@@ -56,6 +56,27 @@ function createPostgresTaskSettlementCoordinator(options) {
56
56
  return {
57
57
  durability: "durable",
58
58
  recovery,
59
+ async hasTerminalCheckpoint(ref) {
60
+ const settlementRef = snapshotSettlementRef(ref);
61
+ if (settlementRef.registryId !== registryId) return false;
62
+ const deliveryKey = {
63
+ ...claimScope,
64
+ deliveryId: stableScope("task-webhook", settlementRef)
65
+ };
66
+ try {
67
+ const task = await readTaskRow(
68
+ pool,
69
+ binding.tableName,
70
+ binding.namespace,
71
+ settlementRef,
72
+ false
73
+ );
74
+ if (!task || !TERMINAL.has(task.status) || task.has_webhook !== true) return false;
75
+ return Boolean(await readOutbox(pool, outboxTable, deliveryKey, false));
76
+ } catch (cause) {
77
+ throw new Error("PostgresTaskSettlementCoordinator.hasTerminalCheckpoint: query failed", { cause });
78
+ }
79
+ },
59
80
  async settle(ref, terminal, push) {
60
81
  const settlementRef = snapshotSettlementRef(ref);
61
82
  if (settlementRef.registryId !== registryId) {
@@ -187,8 +187,19 @@ export interface ServeOptions {
187
187
  * deriving a URL and throw {@link UnknownHostError} for unknown hosts.
188
188
  * Evicted hosts are resolved again on their next request.
189
189
  * Setting {@link trustForwardedHost} is recommended when behind a proxy.
190
+ *
191
+ * @see {@link allowHttpHosts} for explicitly allowlisted development-only
192
+ * plain-HTTP hostnames.
190
193
  */
191
194
  publicUrl?: string | ((host: string) => string);
195
+ /**
196
+ * Exact hostnames permitted to use plain HTTP in {@link publicUrl}, in
197
+ * addition to the built-in loopback allowance. This development-only
198
+ * escape hatch supports private container-network names such as
199
+ * docker-compose service aliases. Entries are case-insensitive and must
200
+ * not contain ports or wildcards. HTTPS remains required by default.
201
+ */
202
+ allowHttpHosts?: string[];
192
203
  /**
193
204
  * Authentication middleware applied to every request. When configured,
194
205
  * missing or invalid credentials produce a 401 with a compliant
@@ -187,8 +187,19 @@ export interface ServeOptions {
187
187
  * deriving a URL and throw {@link UnknownHostError} for unknown hosts.
188
188
  * Evicted hosts are resolved again on their next request.
189
189
  * Setting {@link trustForwardedHost} is recommended when behind a proxy.
190
+ *
191
+ * @see {@link allowHttpHosts} for explicitly allowlisted development-only
192
+ * plain-HTTP hostnames.
190
193
  */
191
194
  publicUrl?: string | ((host: string) => string);
195
+ /**
196
+ * Exact hostnames permitted to use plain HTTP in {@link publicUrl}, in
197
+ * addition to the built-in loopback allowance. This development-only
198
+ * escape hatch supports private container-network names such as
199
+ * docker-compose service aliases. Entries are case-insensitive and must
200
+ * not contain ports or wildcards. HTTPS remains required by default.
201
+ */
202
+ allowHttpHosts?: string[];
192
203
  /**
193
204
  * Authentication middleware applied to every request. When configured,
194
205
  * missing or invalid credentials produce a 401 with a compliant
@@ -104,13 +104,14 @@ function serve(createAgent, options) {
104
104
  );
105
105
  }
106
106
  const publicUrlOption = options?.publicUrl;
107
+ const allowHttpHosts = normalizeAllowHttpHosts(options?.allowHttpHosts);
107
108
  const protectedResourceOption = options?.protectedResource;
108
109
  const publicUrlIsFn = typeof publicUrlOption === "function";
109
110
  const prmIsFn = typeof protectedResourceOption === "function";
110
111
  const authenticationNeedsRawBody = (0, import_auth.authenticatorNeedsRawBody)(options?.authenticate);
111
112
  let staticPublicOrigin;
112
113
  if (typeof publicUrlOption === "string") {
113
- staticPublicOrigin = validatePublicUrl(publicUrlOption, mountPath);
114
+ staticPublicOrigin = validatePublicUrl(publicUrlOption, mountPath, allowHttpHosts);
114
115
  }
115
116
  const MAX_HOST_METADATA_CACHE_ENTRIES = 128;
116
117
  const hostMetadataCache = /* @__PURE__ */ new Map();
@@ -140,7 +141,7 @@ function serve(createAgent, options) {
140
141
  const cached = getHostMetadata(host);
141
142
  if (cached?.publicUrl !== void 0) return cached.publicUrl;
142
143
  const publicUrl = publicUrlOption(canonicalHostnameForScope(host));
143
- const publicOrigin = validatePublicUrl(publicUrl, mountPath);
144
+ const publicOrigin = validatePublicUrl(publicUrl, mountPath, allowHttpHosts);
144
145
  updateHostMetadata(host, { publicUrl, publicOrigin });
145
146
  return publicUrl;
146
147
  };
@@ -165,6 +166,11 @@ function serve(createAgent, options) {
165
166
  "[adcp/serve] No `authenticate` configured \u2014 this agent will accept unauthenticated requests. AdCP security_baseline requires authentication in production."
166
167
  );
167
168
  }
169
+ if (allowHttpHosts.size > 0 && process.env.NODE_ENV === "production") {
170
+ console.warn(
171
+ "[adcp/serve] `allowHttpHosts` enables plaintext HTTP for explicitly named hosts. Remove this development-only option from production deployments."
172
+ );
173
+ }
168
174
  const explicitPreTransport = options?.preTransport;
169
175
  const protectedResourcePath = `/.well-known/oauth-protected-resource${mountPath}`;
170
176
  const httpServer = (0, import_http.createServer)(async (req, res) => {
@@ -545,7 +551,7 @@ function trimTrailingSlashes(s) {
545
551
  while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
546
552
  return s.slice(0, end);
547
553
  }
548
- function validatePublicUrl(publicUrl, mountPath) {
554
+ function validatePublicUrl(publicUrl, mountPath, allowHttpHosts) {
549
555
  let parsed;
550
556
  try {
551
557
  parsed = new URL(publicUrl);
@@ -556,8 +562,11 @@ function validatePublicUrl(publicUrl, mountPath) {
556
562
  throw new Error("serve(): `publicUrl` must not include username or password credentials");
557
563
  }
558
564
  const loopbackHost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
559
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopbackHost)) {
560
- throw new Error("serve(): `publicUrl` must use https (http is allowed only for loopback development URLs)");
565
+ const explicitlyAllowedHttpHost = allowHttpHosts.has(parsed.hostname.toLowerCase());
566
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (loopbackHost || explicitlyAllowedHttpHost))) {
567
+ throw new Error(
568
+ "serve(): `publicUrl` must use https (http is allowed only for loopback or hosts named in `allowHttpHosts`)"
569
+ );
561
570
  }
562
571
  if (trimTrailingSlashes(parsed.pathname) !== trimTrailingSlashes(mountPath)) {
563
572
  throw new Error(
@@ -569,6 +578,27 @@ function validatePublicUrl(publicUrl, mountPath) {
569
578
  }
570
579
  return parsed.origin;
571
580
  }
581
+ function normalizeAllowHttpHosts(entries) {
582
+ const hosts = /* @__PURE__ */ new Set();
583
+ for (const entry of entries ?? []) {
584
+ if (entry.length === 0 || entry.trim() !== entry || entry.includes("*") || entry.includes("?") || entry.includes(":")) {
585
+ throw new Error(
586
+ `serve(): invalid allowHttpHosts entry "${entry}"; use an exact hostname without whitespace, wildcards, or a port`
587
+ );
588
+ }
589
+ let parsed;
590
+ try {
591
+ parsed = new URL(`https://${entry}/`);
592
+ } catch {
593
+ throw new Error(`serve(): invalid allowHttpHosts entry "${entry}"; expected a hostname`);
594
+ }
595
+ if (parsed.hostname.length === 0 || parsed.username.length > 0 || parsed.password.length > 0 || parsed.port.length > 0 || parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0) {
596
+ throw new Error(`serve(): invalid allowHttpHosts entry "${entry}"; expected a hostname without a port`);
597
+ }
598
+ hosts.add(parsed.hostname.toLowerCase());
599
+ }
600
+ return hosts;
601
+ }
572
602
  function hostname(host) {
573
603
  if (host.startsWith("[")) {
574
604
  const end = host.indexOf("]");
@@ -87,13 +87,14 @@ function serve(createAgent, options) {
87
87
  );
88
88
  }
89
89
  const publicUrlOption = options?.publicUrl;
90
+ const allowHttpHosts = normalizeAllowHttpHosts(options?.allowHttpHosts);
90
91
  const protectedResourceOption = options?.protectedResource;
91
92
  const publicUrlIsFn = typeof publicUrlOption === "function";
92
93
  const prmIsFn = typeof protectedResourceOption === "function";
93
94
  const authenticationNeedsRawBody = authenticatorNeedsRawBody(options?.authenticate);
94
95
  let staticPublicOrigin;
95
96
  if (typeof publicUrlOption === "string") {
96
- staticPublicOrigin = validatePublicUrl(publicUrlOption, mountPath);
97
+ staticPublicOrigin = validatePublicUrl(publicUrlOption, mountPath, allowHttpHosts);
97
98
  }
98
99
  const MAX_HOST_METADATA_CACHE_ENTRIES = 128;
99
100
  const hostMetadataCache = /* @__PURE__ */ new Map();
@@ -123,7 +124,7 @@ function serve(createAgent, options) {
123
124
  const cached = getHostMetadata(host);
124
125
  if (cached?.publicUrl !== void 0) return cached.publicUrl;
125
126
  const publicUrl = publicUrlOption(canonicalHostnameForScope(host));
126
- const publicOrigin = validatePublicUrl(publicUrl, mountPath);
127
+ const publicOrigin = validatePublicUrl(publicUrl, mountPath, allowHttpHosts);
127
128
  updateHostMetadata(host, { publicUrl, publicOrigin });
128
129
  return publicUrl;
129
130
  };
@@ -148,6 +149,11 @@ function serve(createAgent, options) {
148
149
  "[adcp/serve] No `authenticate` configured \u2014 this agent will accept unauthenticated requests. AdCP security_baseline requires authentication in production."
149
150
  );
150
151
  }
152
+ if (allowHttpHosts.size > 0 && process.env.NODE_ENV === "production") {
153
+ console.warn(
154
+ "[adcp/serve] `allowHttpHosts` enables plaintext HTTP for explicitly named hosts. Remove this development-only option from production deployments."
155
+ );
156
+ }
151
157
  const explicitPreTransport = options?.preTransport;
152
158
  const protectedResourcePath = `/.well-known/oauth-protected-resource${mountPath}`;
153
159
  const httpServer = createServer(async (req, res) => {
@@ -528,7 +534,7 @@ function trimTrailingSlashes(s) {
528
534
  while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
529
535
  return s.slice(0, end);
530
536
  }
531
- function validatePublicUrl(publicUrl, mountPath) {
537
+ function validatePublicUrl(publicUrl, mountPath, allowHttpHosts) {
532
538
  let parsed;
533
539
  try {
534
540
  parsed = new URL(publicUrl);
@@ -539,8 +545,11 @@ function validatePublicUrl(publicUrl, mountPath) {
539
545
  throw new Error("serve(): `publicUrl` must not include username or password credentials");
540
546
  }
541
547
  const loopbackHost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
542
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopbackHost)) {
543
- throw new Error("serve(): `publicUrl` must use https (http is allowed only for loopback development URLs)");
548
+ const explicitlyAllowedHttpHost = allowHttpHosts.has(parsed.hostname.toLowerCase());
549
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (loopbackHost || explicitlyAllowedHttpHost))) {
550
+ throw new Error(
551
+ "serve(): `publicUrl` must use https (http is allowed only for loopback or hosts named in `allowHttpHosts`)"
552
+ );
544
553
  }
545
554
  if (trimTrailingSlashes(parsed.pathname) !== trimTrailingSlashes(mountPath)) {
546
555
  throw new Error(
@@ -552,6 +561,27 @@ function validatePublicUrl(publicUrl, mountPath) {
552
561
  }
553
562
  return parsed.origin;
554
563
  }
564
+ function normalizeAllowHttpHosts(entries) {
565
+ const hosts = /* @__PURE__ */ new Set();
566
+ for (const entry of entries ?? []) {
567
+ if (entry.length === 0 || entry.trim() !== entry || entry.includes("*") || entry.includes("?") || entry.includes(":")) {
568
+ throw new Error(
569
+ `serve(): invalid allowHttpHosts entry "${entry}"; use an exact hostname without whitespace, wildcards, or a port`
570
+ );
571
+ }
572
+ let parsed;
573
+ try {
574
+ parsed = new URL(`https://${entry}/`);
575
+ } catch {
576
+ throw new Error(`serve(): invalid allowHttpHosts entry "${entry}"; expected a hostname`);
577
+ }
578
+ if (parsed.hostname.length === 0 || parsed.username.length > 0 || parsed.password.length > 0 || parsed.port.length > 0 || parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0) {
579
+ throw new Error(`serve(): invalid allowHttpHosts entry "${entry}"; expected a hostname without a port`);
580
+ }
581
+ hosts.add(parsed.hostname.toLowerCase());
582
+ }
583
+ return hosts;
584
+ }
555
585
  function hostname(host) {
556
586
  if (host.startsWith("[")) {
557
587
  const end = host.indexOf("]");
@@ -10,7 +10,13 @@ export type BrandJsonResolverErrorCode = 'invalid_url' | 'invalid_house' | 'redi
10
10
  */
11
11
  export declare class BrandJsonResolverError extends Error {
12
12
  readonly code: BrandJsonResolverErrorCode;
13
- constructor(code: BrandJsonResolverErrorCode, message: string);
13
+ readonly cause?: unknown;
14
+ /** HTTP status for `fetch_failed` responses, when a response was received. */
15
+ readonly httpStatus?: number;
16
+ constructor(code: BrandJsonResolverErrorCode, message: string, details?: {
17
+ httpStatus?: number;
18
+ cause?: unknown;
19
+ });
14
20
  }
15
21
  export interface BrandJsonJwksResolverOptions {
16
22
  /** Functional role of the agent whose keys we want to resolve. */
@@ -99,3 +105,40 @@ export declare class BrandJsonJwksResolver implements JwksResolver {
99
105
  private refresh;
100
106
  private doRefresh;
101
107
  }
108
+ export interface FetchedBrandJson {
109
+ status: 'ok' | 'not_modified';
110
+ finalUrl: string;
111
+ data: unknown;
112
+ etag?: string;
113
+ cacheControl?: string;
114
+ }
115
+ export interface FetchBrandJsonOptions {
116
+ /** Entry-point URL. HTTPS and public addresses are required by default. */
117
+ startUrl: string;
118
+ /** ETag sent only to the entry URL for cache revalidation. */
119
+ currentEtag?: string;
120
+ /** Maximum JSON-level `authoritative_location` / `house` hops. Default 3, hard maximum 10. */
121
+ maxRedirects?: number;
122
+ /** Permit HTTP and private addresses for controlled development environments. */
123
+ allowPrivateIp?: boolean;
124
+ /** Whole-request deadline per hop. Default and hard maximum 10 seconds. */
125
+ timeoutMs?: number;
126
+ /** Response-body cap per hop. Default and hard maximum 256 KiB. */
127
+ maxBodyBytes?: number;
128
+ }
129
+ /**
130
+ * Fetch brand.json from `startUrl`, following `authoritative_location` and
131
+ * `house` string redirect variants up to `maxRedirects` hops. Each hop goes
132
+ * through the SSRF-safe fetch primitive so an attacker-supplied chain can't
133
+ * land on a private address or IMDS. Redirect targets are structurally
134
+ * validated before dispatch — an attacker-controlled brand.json that emits
135
+ * `{"house": "evil.com\\@victim.com"}` or `{"authoritative_location":
136
+ * "http://169.254.169.254/..."}` is rejected at parse time rather than
137
+ * relying on `ssrfSafeFetch` to catch every pathological shape.
138
+ *
139
+ * This low-level function is intentionally stateless. Callers MUST add
140
+ * response caching and a minimum refresh cooldown rather than invoking it on
141
+ * every authorization request. Prefer `BrandJsonJwksResolver` when resolving
142
+ * signing keys; it provides both safeguards.
143
+ */
144
+ export declare function fetchBrandJson(args: FetchBrandJsonOptions): Promise<FetchedBrandJson>;