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-pack.js b/lib/wrangler-pack.js index a715379..f1548db 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, @@ -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] @@ -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 }; } @@ -195,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; @@ -236,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 }); diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 5742c37..bde94d6 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -201,6 +201,48 @@ 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`); + } + // 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) { + const entry = asRecord(rawEntry); + 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'`); + } + 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`); + } + // 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; +} + /** * @param {WranglerConfig} cfg * @param {string} [configRel] @@ -305,14 +347,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] */ /** @@ -348,6 +389,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( @@ -359,21 +404,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; @@ -382,7 +431,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 []; @@ -395,7 +444,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)) { @@ -433,7 +482,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); @@ -471,14 +520,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(); @@ -538,7 +587,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] diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 396581d..dc38ced 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,42 @@ 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.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/); + 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'/); + 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/); + // 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 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", () => { assert.deepEqual(parseServicesFromCfg({}), []); assert.deepEqual(parseServicesFromCfg({ services: [] }), []); @@ -1750,6 +1787,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 {