From 524c175b1481e21de5fafac7a4a8ea86b0a03374 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:26:10 +0000 Subject: [PATCH 1/8] Harden kv_namespaces and assets parser contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the last two loose wrangler parsers in line with the "map or loudly reject" contract the d1/r2/services parsers follow: - Add parseKvNamespacesFromCfg (exported, unit-tested) replacing the inline kv_namespaces loop. It rejects a non-array table list, a non-table entry, and a missing/empty/non-string `binding` or `id` — previously `cfg.kv_namespaces || []` was cast and iterated, so a non-array gave a confusing error and a non-string binding/id was String()-coerced past the binding-name regex. - Reject a present-but-non-table [assets] block instead of letting asRecord() null it out and silently skip assets. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 14 ++++++++------ lib/wrangler/bindings.js | 30 ++++++++++++++++++++++++++++++ tests/unit/cli-deploy.test.js | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index a715379..82bada8 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -13,11 +13,10 @@ import { } from "./ns-pattern.js"; import { collectAssets, resolveAssetsDir } from "./wrangler/assets.js"; import { - assertNotRuntimeReservedBinding, - assertValidBindingName, parseD1DatabasesFromCfg, parseDurableObjectsFromCfg, parseExportsFromCfg, + parseKvNamespacesFromCfg, parsePlatformBindingsFromCfg, parseQueues, parseR2BucketsFromCfg, @@ -52,6 +51,7 @@ export { parseD1DatabasesFromCfg, parseDurableObjectsFromCfg, parseExportsFromCfg, + parseKvNamespacesFromCfg, parsePlatformBindingsFromCfg, parseQueues, parseR2BucketsFromCfg, @@ -171,11 +171,8 @@ export async function packWranglerProject({ claimedBindings.add(name); }; - const kvList = /** @type {Array<{binding?: string, id?: string}>} */ (cfg.kv_namespaces || []); + const kvList = wrapCli(() => parseKvNamespacesFromCfg(cfg, configRel)); for (const kv of kvList) { - if (!kv.binding || !kv.id) throw new CliError("[[kv_namespaces]] entry needs both 'binding' and 'id'"); - wrapCli(() => assertValidBindingName(configRel, "[[kv_namespaces]]", kv.binding)); - assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", kv.binding); claimBinding(kv.binding); bindings[kv.binding] = { type: "kv", id: kv.id }; } @@ -314,6 +311,11 @@ export async function packWranglerProject({ ); } + // A present-but-non-table [assets] is a config error, not "no assets": reject + // it instead of letting asRecord() null it out and silently skip assets. + if (cfg.assets != null && asRecord(cfg.assets) == null) { + throw new CliError(`${configRel}: [assets] must be a table`); + } const assetsCfg = asRecord(cfg.assets); const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; // Gate on "present", not truthy, so an empty/malformed directory reaches the diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 5742c37..452cfd8 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -201,6 +201,36 @@ export function parseQueues(queues, configRel = "wrangler config") { return { producers, consumers }; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ binding: string, id: string }>} + */ +export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { + if (cfg.kv_namespaces == null) return []; + if (!Array.isArray(cfg.kv_namespaces)) { + throw new Error(`${configRel}: [[kv_namespaces]] must be an array of tables`); + } + /** @type {Array<{ binding: string, id: string }>} */ + const out = []; + for (const rawEntry of cfg.kv_namespaces) { + const entry = asRecord(rawEntry); + if (!entry) { + throw new Error(`${configRel}: [[kv_namespaces]] entry must be a table`); + } + if (typeof entry.binding !== "string" || !entry.binding.trim()) { + throw new Error(`${configRel}: [[kv_namespaces]] entry needs a non-empty string 'binding'`); + } + assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", entry.binding); + assertValidBindingName(configRel, "[[kv_namespaces]]", entry.binding); + if (typeof entry.id !== "string" || !entry.id.trim()) { + throw new Error(`${configRel}: [[kv_namespaces]] ${entry.binding}: 'id' must be a non-empty string`); + } + out.push({ binding: entry.binding, id: entry.id }); + } + return out; +} + /** * @param {WranglerConfig} cfg * @param {string} [configRel] diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 396581d..ecca908 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -18,6 +18,7 @@ import { parseDurableObjectsFromCfg, parseExportsFromCfg, parseJsonc, + parseKvNamespacesFromCfg, parsePlatformBindingsFromCfg, parseQueues, parseR2BucketsFromCfg, @@ -472,6 +473,26 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" ); }); +test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { + assert.deepEqual(parseKvNamespacesFromCfg({}), []); + assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [] }), []); + assert.deepEqual( + parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }), + [{ binding: "KV", id: "abc" }] + ); + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: {} }), /must be an array/); + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [null] }), /entry must be a table/); + // binding: missing / empty / non-string + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ id: "x" }] }), /needs a non-empty string 'binding'/); + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "", id: "x" }] }), /needs a non-empty string 'binding'/); + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: ["KV"], id: "x" }] }), /needs a non-empty string 'binding'/); + // id: missing / non-string + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV" }] }), /'id' must be a non-empty string/); + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), /'id' must be a non-empty string/); + // binding name grammar still enforced + assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "bad-kv", id: "x" }] }), /binding must match/); +}); + test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { assert.deepEqual(parseServicesFromCfg({}), []); assert.deepEqual(parseServicesFromCfg({ services: [] }), []); From a9becf47bef8f05ee45165b29366bccfeed7d677 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:29:12 +0000 Subject: [PATCH 2/8] Tighten parser field types via type-predicate validators The wrangler parsers validated several fields at runtime but typed them `unknown` because the validators weren't TS type predicates. Annotate isValidJsClassDeclarationName, isAdminAcceptableNs, and isReservedNs as `value is string` predicates (JSDoc only; runtime unchanged, regex/logic stays in sync with shared/ns-pattern.js), and capture the service entrypoint/ns in the same block as their validation so the narrowing flows. The parser results now type these as `string` instead of `unknown`: - ServiceBinding `entrypoint` / `ns` - Durable Object and Workflow `className` - export `entrypoint` Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/ns-pattern.js | 15 ++++++++++++--- lib/wrangler/bindings.js | 31 +++++++++++++++++++------------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lib/ns-pattern.js b/lib/ns-pattern.js index 79a8d48..be86ef9 100644 --- a/lib/ns-pattern.js +++ b/lib/ns-pattern.js @@ -78,7 +78,10 @@ export const JS_CLASS_DECLARATION_RESERVED_WORDS = new Set([ "yield", ]); -/** @param {unknown} value */ +/** + * @param {unknown} value + * @returns {value is string} + */ export function isValidJsClassDeclarationName(value) { return isValidJsIdentifier(value) && !JS_CLASS_DECLARATION_RESERVED_WORDS.has(value); } @@ -115,12 +118,18 @@ export const WDL_RESERVED_ENTRYPOINT_RE = /^__Wdl[A-Za-z0-9_]*__$/; const NS_RE = new RegExp(`^${NS_PATTERN}$`); -/** @param {unknown} ns */ +/** + * @param {unknown} ns + * @returns {ns is string} + */ export function isReservedNs(ns) { return typeof ns === "string" && RESERVED_NS_SECTION_RE.test(ns); } -/** @param {unknown} ns */ +/** + * @param {unknown} ns + * @returns {ns is string} + */ export function isAdminAcceptableNs(ns) { if (typeof ns !== "string") return false; if (RESERVED_TENANT_NS.has(ns)) return false; diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 452cfd8..339ecae 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -335,14 +335,13 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { /** * `binding` and `service` are validated as non-empty strings; `entrypoint` and - * `ns`, when present, are checked as a JS identifier / admin-acceptable namespace - * (both strings at that point) but stay `unknown` here since the validators are - * not TS type predicates. + * `ns`, when present, are validated as a JS identifier / admin-acceptable + * namespace (both narrowed to string by their type-predicate validators). * @typedef {object} ServiceBinding * @property {string} binding * @property {string} service - * @property {unknown} [entrypoint] - * @property {unknown} [ns] + * @property {string} [entrypoint] + * @property {string} [ns] */ /** @@ -378,6 +377,10 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { } assertNotRuntimeReservedBinding(configRel, "[[services]]", entry.binding); assertValidBindingName(configRel, "[[services]]", entry.binding); + // Validate and capture in the same block so the predicate narrowing flows + // into the typed entrypoint/ns fields below. + /** @type {string | undefined} */ + let entrypoint; if (entry.entrypoint != null) { if (!isValidJsIdentifier(entry.entrypoint)) { throw new Error( @@ -389,21 +392,25 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { `${configRel}: [[services]] ${entry.binding}: entrypoint ${JSON.stringify(entry.entrypoint)} is reserved for runtime-injected entrypoints` ); } + entrypoint = entry.entrypoint; } + /** @type {string | undefined} */ + let ns; if (entry.ns != null) { if (!isAdminAcceptableNs(entry.ns)) { throw new Error( `${configRel}: [[services]] ${entry.binding}: ns must match ${NS_PATTERN} or an operator-reserved namespace, got ${JSON.stringify(entry.ns)}` ); } + ns = entry.ns; } /** @type {ServiceBinding} */ const normalized = { binding: entry.binding, service: entry.service, }; - if (entry.entrypoint != null) normalized.entrypoint = entry.entrypoint; - if (entry.ns != null) normalized.ns = entry.ns; + if (entrypoint != null) normalized.entrypoint = entrypoint; + if (ns != null) normalized.ns = ns; out.push(normalized); } return out; @@ -412,7 +419,7 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { /** * @param {WranglerConfig} cfg * @param {string} [configRel] - * @returns {Array<{ binding: string, className: unknown }>} + * @returns {Array<{ binding: string, className: string }>} */ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { if (cfg.durable_objects == null) return []; @@ -463,7 +470,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { } } - /** @type {Array<{ binding: string, className: unknown }>} */ + /** @type {Array<{ binding: string, className: string }>} */ const out = []; for (const rawEntry of bindingList) { const entry = asRecord(rawEntry); @@ -501,14 +508,14 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { /** * @param {WranglerConfig} cfg * @param {string} [configRel] - * @returns {Array<{ name: string, binding: string, className: unknown }>} + * @returns {Array<{ name: string, binding: string, className: string }>} */ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { if (cfg.workflows == null) return []; if (!Array.isArray(cfg.workflows)) { throw new Error(`${configRel}: [[workflows]] must be an array of tables`); } - /** @type {Array<{ name: string, binding: string, className: unknown }>} */ + /** @type {Array<{ name: string, binding: string, className: string }>} */ const out = []; /** @type {Set} */ const seenNames = new Set(); @@ -568,7 +575,7 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { /** * @typedef {object} ExportEntry - * @property {unknown} entrypoint Either "default" or a validated JS class name. + * @property {string} entrypoint Either "default" or a validated JS class name. * @property {string[]} allowedCallers * @property {string} [as] * @property {string[]} [requiredCallerSecrets] From e5a77d0dccdd75c5b77138bea3667493223aeeb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:37:55 +0000 Subject: [PATCH 3/8] Trim kv id and tighten newClasses to Set Two PR-review follow-ups: - parseKvNamespacesFromCfg stored the untrimmed `id`, so "abc " passed validation but forwarded trailing whitespace. Store `.trim()`ed binding/id like the d1/r2 parsers; add coverage. - newClasses no longer needs Set now that isValidJsClassDeclarationName is a `value is string` predicate and class_name narrows to string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler/bindings.js | 6 ++++-- tests/unit/cli-deploy.test.js | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 339ecae..cb8289e 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -226,7 +226,9 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { if (typeof entry.id !== "string" || !entry.id.trim()) { throw new Error(`${configRel}: [[kv_namespaces]] ${entry.binding}: 'id' must be a non-empty string`); } - out.push({ binding: entry.binding, id: entry.id }); + // Store trimmed values like the d1/r2 parsers, so a stray "abc " id isn't + // forwarded with whitespace. + out.push({ binding: entry.binding.trim(), id: entry.id.trim() }); } return out; } @@ -432,7 +434,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { if (!Array.isArray(bindingList)) { throw new Error(`${configRel}: [[durable_objects.bindings]] must be an array of tables`); } - /** @type {Set} */ + /** @type {Set} */ const newClasses = new Set(); const migrations = cfg.migrations == null ? [] : cfg.migrations; if (!Array.isArray(migrations)) { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index ecca908..df1a384 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -480,6 +480,11 @@ test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id" parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }), [{ binding: "KV", id: "abc" }] ); + // stored values are trimmed, like the d1/r2 parsers + assert.deepEqual( + parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc " }] }), + [{ binding: "KV", id: "abc" }] + ); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: {} }), /must be an array/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [null] }), /entry must be a table/); // binding: missing / empty / non-string From 21a53d79115240fad459260ba2f0bed48221d840 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:43:56 +0000 Subject: [PATCH 4/8] Reject a non-table [assets] before bundling, with coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [assets]-must-be-a-table check ran during manifest assembly, after the wrangler dry-run bundle — so a malformed config bundled first and was also untestable via the standard "reject before bundling" pattern. Move it next to the other pre-bundle cfg-shape checks (name/main/vars) so it fails fast, and add a runDeployCommand test asserting it rejects before execFile is called. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 11 ++++++----- tests/unit/cli-deploy.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 82bada8..8ae5001 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -233,6 +233,12 @@ export async function packWranglerProject({ } const vars = normalizeVars(cfg.vars); + // A present-but-non-table [assets] is a config error, not "no assets": reject + // it before bundling instead of letting asRecord() null it out and silently + // skip assets. + if (cfg.assets != null && asRecord(cfg.assets) == null) { + throw new CliError(`${configRel}: [assets] must be a table`); + } const outDir = path.join(absProject, ".deploy-dist"); rmSync(outDir, { recursive: true, force: true }); @@ -311,11 +317,6 @@ export async function packWranglerProject({ ); } - // A present-but-non-table [assets] is a config error, not "no assets": reject - // it instead of letting asRecord() null it out and silently skip assets. - if (cfg.assets != null && asRecord(cfg.assets) == null) { - throw new CliError(`${configRel}: [assets] must be a table`); - } const assetsCfg = asRecord(cfg.assets); const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; // Gate on "present", not truthy, so an empty/malformed directory reaches the diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index df1a384..e6b74b7 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1776,6 +1776,34 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid } }); +test("runDeployCommand rejects a non-table [assets] before bundling", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-assets-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ + name: "api", + main: "src/index.js", + assets: "public", + })); + + let execCalled = false; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + { message: "wrangler.json: [assets] must be a table" } + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand rejects non-object vars before bundling", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-")); try { From fdbfb6f406d765dbc98370094a68169a4ddf4647 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:54:19 +0000 Subject: [PATCH 5/8] Tighten WorkerManifest workflow/service field types to string Now that parseWorkflowsFromCfg and parseServicesFromCfg return string-typed className/service/entrypoint/ns, reflect that in the manifest types instead of leaving them `unknown`: WorkerManifest.workflows[].className and the inline service-binding entry (service, entrypoint, ns) are now `string`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 8ae5001..f1548db 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -89,7 +89,7 @@ const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024; * @property {string[]} [routes] * @property {Array<{ cron: string, timezone: string }>} [crons] * @property {import("./wrangler/bindings.js").QueueConsumer[]} [queueConsumers] - * @property {Array<{ name: string, binding: string, className: unknown }>} [workflows] + * @property {Array<{ name: string, binding: string, className: string }>} [workflows] * @property {import("./wrangler/bindings.js").ExportEntry[]} [exports] * @property {Array<{ binding: string, platform: string }>} [platformBindings] * @property {Record} [assets] @@ -192,7 +192,7 @@ export async function packWranglerProject({ const svcList = wrapCli(() => parseServicesFromCfg(cfg, configRel)); for (const svc of svcList) { claimBinding(svc.binding); - /** @type {{ type: string, service: unknown, entrypoint?: unknown, ns?: unknown }} */ + /** @type {{ type: string, service: string, entrypoint?: string, ns?: string }} */ const entry = { type: "service", service: svc.service }; if (svc.entrypoint && svc.entrypoint !== "default") entry.entrypoint = svc.entrypoint; if (svc.ns) entry.ns = svc.ns; From 91d627cb88d30e664fa1f76ec61701c2d3f81287 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 02:40:07 +0000 Subject: [PATCH 6/8] Reject unknown keys in parseKvNamespacesFromCfg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowlist binding/id/preview_id and throw on any other [[kv_namespaces]] entry key, matching the d1/r2 parsers — so a typo or unsupported field is loudly rejected instead of silently ignored. preview_id (a `wrangler dev` concept WDL doesn't use) is accepted but ignored. Add coverage for both. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler/bindings.js | 9 +++++++++ tests/unit/cli-deploy.test.js | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index cb8289e..bc14b61 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -211,6 +211,9 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { if (!Array.isArray(cfg.kv_namespaces)) { throw new Error(`${configRel}: [[kv_namespaces]] must be an array of tables`); } + // preview_id is a Wrangler `wrangler dev` concept WDL doesn't use; allow it + // (ignored) but reject other unknown keys, matching the d1/r2 parsers. + const allowedKeys = new Set(["binding", "id", "preview_id"]); /** @type {Array<{ binding: string, id: string }>} */ const out = []; for (const rawEntry of cfg.kv_namespaces) { @@ -218,6 +221,12 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { if (!entry) { throw new Error(`${configRel}: [[kv_namespaces]] entry must be a table`); } + const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); + if (unknownKeys.length > 0) { + throw new Error( + `${configRel}: [[kv_namespaces]] contains unknown field(s): ${unknownKeys.join(", ")}` + ); + } if (typeof entry.binding !== "string" || !entry.binding.trim()) { throw new Error(`${configRel}: [[kv_namespaces]] entry needs a non-empty string 'binding'`); } diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index e6b74b7..f52fb6e 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -496,6 +496,16 @@ test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id" assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), /'id' must be a non-empty string/); // binding name grammar still enforced assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "bad-kv", id: "x" }] }), /binding must match/); + // unknown keys (typos) are rejected, like the d1/r2 parsers + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", bindng: "typo" }] }), + /unknown field\(s\): bindng/ + ); + // Wrangler's preview_id is allowed but ignored + assert.deepEqual( + parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", preview_id: "p" }] }), + [{ binding: "KV", id: "x" }] + ); }); test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { From 840dc9a75d138ccaf755a008b340dfc9f30bc577 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 02:58:02 +0000 Subject: [PATCH 7/8] Allow Wrangler's remote key in kv_namespaces `remote` (force `wrangler dev` to use real Cloudflare KV) is a valid Wrangler v4 local-dev field with no meaning for WDL deploy. Add it to the kv allowlist alongside preview_id so it's accepted but ignored (only binding/id are forwarded to the manifest); add coverage. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler/bindings.js | 7 ++++--- tests/unit/cli-deploy.test.js | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index bc14b61..bde94d6 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -211,9 +211,10 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { if (!Array.isArray(cfg.kv_namespaces)) { throw new Error(`${configRel}: [[kv_namespaces]] must be an array of tables`); } - // preview_id is a Wrangler `wrangler dev` concept WDL doesn't use; allow it - // (ignored) but reject other unknown keys, matching the d1/r2 parsers. - const allowedKeys = new Set(["binding", "id", "preview_id"]); + // preview_id and remote are Wrangler `wrangler dev` (local-dev) concepts WDL + // doesn't use; allow them (ignored — only binding/id are forwarded) but reject + // other unknown keys, matching the d1/r2 parsers. + const allowedKeys = new Set(["binding", "id", "preview_id", "remote"]); /** @type {Array<{ binding: string, id: string }>} */ const out = []; for (const rawEntry of cfg.kv_namespaces) { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index f52fb6e..9bbdeac 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -501,11 +501,15 @@ test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id" () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", bindng: "typo" }] }), /unknown field\(s\): bindng/ ); - // Wrangler's preview_id is allowed but ignored + // Wrangler's local-dev keys (preview_id, remote) are allowed but ignored assert.deepEqual( parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", preview_id: "p" }] }), [{ binding: "KV", id: "x" }] ); + assert.deepEqual( + parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", remote: true }] }), + [{ binding: "KV", id: "x" }] + ); }); test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { From 43666ce9633bf4fe3a0ddb242d55f1fe201f7900 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:07:42 +0000 Subject: [PATCH 8/8] Drop redundant comment labels in the kv parser test Three test-section labels just restated what the assert arguments already show (trimmed values, missing/empty/non-string binding, missing/non-string id). Remove them; the comments that convey non-obvious intent (grammar still enforced, d1/r2 consistency, allow-but-ignore) stay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- tests/unit/cli-deploy.test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 9bbdeac..dc38ced 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -480,18 +480,15 @@ test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id" parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }), [{ binding: "KV", id: "abc" }] ); - // stored values are trimmed, like the d1/r2 parsers assert.deepEqual( parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc " }] }), [{ binding: "KV", id: "abc" }] ); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: {} }), /must be an array/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [null] }), /entry must be a table/); - // binding: missing / empty / non-string assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ id: "x" }] }), /needs a non-empty string 'binding'/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "", id: "x" }] }), /needs a non-empty string 'binding'/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: ["KV"], id: "x" }] }), /needs a non-empty string 'binding'/); - // id: missing / non-string assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV" }] }), /'id' must be a non-empty string/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), /'id' must be a non-empty string/); // binding name grammar still enforced