@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.
- package/bin/adcp.js +154 -6
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.d.mts +10 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.d.ts +10 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +21 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +21 -0
- package/dist/lib/server/serve.d.mts +11 -0
- package/dist/lib/server/serve.d.ts +11 -0
- package/dist/lib/server/serve.js +35 -5
- package/dist/lib/server/serve.mjs +35 -5
- package/dist/lib/signing/brand-jwks.d.mts +44 -1
- package/dist/lib/signing/brand-jwks.d.ts +44 -1
- package/dist/lib/signing/brand-jwks.js +53 -15
- package/dist/lib/signing/brand-jwks.mjs +51 -14
- package/dist/lib/signing/server.d.mts +1 -1
- package/dist/lib/signing/server.d.ts +1 -1
- package/dist/lib/signing/server.js +2 -0
- package/dist/lib/signing/server.mjs +3 -1
- package/dist/lib/testing/storyboard/runner.js +6 -2
- package/dist/lib/testing/storyboard/runner.mjs +6 -2
- package/dist/lib/testing/storyboard/types.d.mts +6 -0
- package/dist/lib/testing/storyboard/types.d.ts +6 -0
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/testing/storyboard/webhook-assertions.js +6 -1
- package/dist/lib/testing/storyboard/webhook-assertions.mjs +6 -1
- package/dist/lib/testing/storyboard/webhook-receiver.d.mts +15 -0
- package/dist/lib/testing/storyboard/webhook-receiver.d.ts +15 -0
- package/dist/lib/testing/storyboard/webhook-receiver.js +55 -15
- package/dist/lib/testing/storyboard/webhook-receiver.mjs +55 -15
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/llms.txt +1 -1
- 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.
|
|
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]
|
|
3035
|
-
*
|
|
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)
|
|
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: {
|
|
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-
|
|
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
|
package/dist/lib/server/serve.js
CHANGED
|
@@ -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
|
-
|
|
560
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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>;
|