diff options
| -rw-r--r-- | bun.lock | 5 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | scripts/network-policy.test.ts | 135 | ||||
| -rw-r--r-- | scripts/network-policy.ts | 165 | ||||
| -rw-r--r-- | vite.config.ts | 136 |
5 files changed, 432 insertions, 10 deletions
@@ -25,6 +25,7 @@ "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", + "@types/node": "^26.6.5", "daisyui": "^5.5.20", "fake-indexeddb": "^6.0.0", "jsdom": "^25.0.0", @@ -262,6 +263,8 @@ "@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + "@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], @@ -548,6 +551,8 @@ "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite-node": ["[email protected]", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], diff --git a/package.json b/package.json index de9c93d..28e239c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", + "@types/node": "^26.6.5", "daisyui": "^5.5.20", "fake-indexeddb": "^6.0.0", "jsdom": "^25.0.0", diff --git a/scripts/network-policy.test.ts b/scripts/network-policy.test.ts new file mode 100644 index 0000000..af7d870 --- /dev/null +++ b/scripts/network-policy.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + buildAllowedHosts, + checkDevRequest, + checkHost, + isAllowedLocalAddress, + normalizeHost, + normalizeHostHeader, + tailscaleSelf, +} from "./network-policy"; + +const tailscaleStatusJson = `{"Self":{"HostName":"laptop", + "DNSName":"PII_REDACTED_TAILSCALE_HOST_01.", + "TailscaleIPs":["PII_REDACTED_DEV_IPV4_01","PII_REDACTED_DEV_IPV6_01"]}}`; +const allowedHosts = buildAllowedHosts({ tailscaleStatusJson, env: {} }); +const allowedAddresses = new Set(["127.0.0.1", "::1", ...tailscaleSelf(tailscaleStatusJson).ips]); +const policy = { allowedHosts, allowedAddresses }; +const request = (localAddress = "::1", host: string | undefined = "localhost", origin?: string) => + checkDevRequest({ localAddress, host, origin }, policy); + +describe("backend-compatible hosts", () => { + it.each([ + ["LOCALHOST.:34204", "localhost"], + ["127.0.0.1:34204", "127.0.0.1"], + ["[0:0:0:0:0:0:0:1]:34204", "::1"], + ["PII_REDACTED_DEVICE_01", "PII_REDACTED_DEVICE_01"], + ["PII_REDACTED_TAILSCALE_HOST_01.:34204", "PII_REDACTED_TAILSCALE_HOST_01"], + ["PII_REDACTED_DEV_IPV4_01:34204", "PII_REDACTED_DEV_IPV4_01"], + ["[PII_REDACTED_DEV_IPV6_01]:34204", "PII_REDACTED_DEV_IPV6_01"], + ])("normalizes and allows %s", (raw, expected) => { + expect(normalizeHost(raw)).toBe(expected); + expect(checkHost(raw, allowedHosts)).toBe("ok"); + }); + + it.each( + ( + "evil.com|PII_REDACTED_DEVICE_01.evil.com|PII_REDACTED_DEV_IPV4_01.nip.io|laptop|localhost.evil.com|" + + "sub.localhost|127.0.0.2|100.1.2.3|172.17.0.1|[email protected]|localhost/path|" + + "localhost:bad|localhost:65536|[::1]suffix|localhost\n|localhost..|%6cocalhost|" + ).split("|"), + )("rejects %j even where Vite would allow it", (host) => { + expect(checkHost(host, allowedHosts)).toMatchObject({ status: 421 }); + }); + + const missingStatuses = [undefined, "invalid", "null", "[]", "{}", '{"Self":null}']; + it.each(missingStatuses)("fails closed for status %j, retains env overrides", (json) => { + const input = { + ...(json !== undefined ? { tailscaleStatusJson: json } : {}), + env: { + DISPATCH_ALLOWED_HOSTS: " EXTRA.example.:8080, https://evil.com", + DISPATCH_TAILSCALE_NAME: "PII_REDACTED_DEVICE_01", + }, + }; + const expected = "localhost,127.0.0.1,::1,extra.example,PII_REDACTED_DEVICE_01".split(","); + expect([...buildAllowedHosts(input)]).toEqual(expected); + expect(tailscaleSelf(json)).toEqual({ ips: [] }); + }); + + it("keeps auto-detection when env hosts are supplied, without trusting OS hostname", () => { + const hosts = buildAllowedHosts({ + tailscaleStatusJson, + env: { DISPATCH_ALLOWED_HOSTS: "extra.example" }, + }); + expect(hosts.has("PII_REDACTED_TAILSCALE_HOST_01")).toBe(true); + expect(hosts.has("extra.example")).toBe(true); + expect(hosts.has("laptop")).toBe(false); + expect(tailscaleSelf(tailscaleStatusJson).hostName).toBe("laptop"); + expect( + tailscaleSelf('{"Self":{"TailscaleIPs":[null,"evil.com","PII_REDACTED_DEV_IPV4_01"]}}').ips, + ).toEqual(["PII_REDACTED_DEV_IPV4_01"]); + }); + + it.each([ + ["LOCALHOST.:34204", "localhost:34204"], + ["[0:0:0:0:0:0:0:1]:34204", "[::1]:34204"], + ["[::1]", "[::1]"], + ["PII_REDACTED_DEVICE_01.", "PII_REDACTED_DEVICE_01"], + ])("preserves valid authorities for Vite: %s", (raw, expected) => { + expect(normalizeHostHeader(raw)).toBe(expected); + }); +}); + +describe("destination interfaces (not remote peer addresses)", () => { + it.each( + ( + "127.0.0.1|::1|0:0:0:0:0:0:0:1|::ffff:127.0.0.1|::ffff:7f00:1|" + + "PII_REDACTED_DEV_IPV4_01|::ffff:PII_REDACTED_DEV_IPV4_01|PII_REDACTED_DEV_IPV6_01" + ).split("|"), + )("allows local address %s", (address) => { + expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(true); + }); + + it.each([ + undefined, + ..."|172.17.0.1|::ffff:172.17.0.1|192.168.1.2|100.1.2.3|0.0.0.0|::|127.0.0.2".split("|"), + ])("rejects non-selected interface %j", (address) => { + expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(false); + // undefined must not use the helper's default loopback address. + expect( + checkDevRequest({ localAddress: address, host: "localhost", origin: undefined }, policy), + ).toMatchObject({ status: 403 }); + }); + + it("does not treat an env host extra as an allowed interface", () => { + const hosts = buildAllowedHosts({ env: { DISPATCH_ALLOWED_HOSTS: "172.17.0.1" } }); + expect( + checkDevRequest( + { localAddress: "172.17.0.1", host: "172.17.0.1", origin: undefined }, + { allowedHosts: hosts, allowedAddresses }, + ), + ).toMatchObject({ status: 403 }); + }); +}); + +describe("shared HTTP / HMR request decision", () => { + it.each([ + undefined, + "http://PII_REDACTED_DEVICE_01.:34204", + "https://[PII_REDACTED_DEV_IPV6_01]:443", + ])("allows trusted or absent Origin %j", (origin) => { + expect(request("::ffff:PII_REDACTED_DEV_IPV4_01", "PII_REDACTED_DEVICE_01", origin)).toBe("ok"); + }); + const badOrigins = + "null|file://localhost|https://evil.com|http://localhost/|http://user@localhost"; + it.each(badOrigins.split("|"))("rejects Origin %j", (origin) => { + expect(request("::1", "localhost", origin)).toMatchObject({ status: 403 }); + }); + it("requires Host and checks it before Origin", () => { + for (const host of [undefined, "evil.com"]) { + expect( + checkDevRequest({ localAddress: "127.0.0.1", host, origin: "null" }, policy), + ).toMatchObject({ status: 421 }); + } + }); +}); diff --git a/scripts/network-policy.ts b/scripts/network-policy.ts new file mode 100644 index 0000000..3bb122c --- /dev/null +++ b/scripts/network-policy.ts @@ -0,0 +1,165 @@ +/** Host/Origin semantics mirrored from backend packages/network-policy (review step 2.4). + * Keep in sync there; no I/O or ambient environment reads in this module. + */ +export interface NetworkPolicyEnv { + readonly DISPATCH_ALLOWED_HOSTS?: string; + readonly DISPATCH_TAILSCALE_NAME?: string; +} + +export type PolicyDecision<Status extends number> = "ok" | { status: Status; reason: string }; +type HeaderValue = string | null | undefined; + +function ipAddress(raw: string): string { + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(raw)) { + return raw.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part) + ? raw + : ""; + } + if (!raw.includes(":") || /[^\da-f:.]/i.test(raw)) return ""; + try { + return new URL(`http://[${raw}]`).hostname.slice(1, -1); + } catch { + return ""; + } +} + +/** Invalid authorities normalize to "" (fail closed). Bare IPv6 is also accepted. */ +export function normalizeHost(raw: string): string { + const host = raw.toLowerCase(); + if (host.includes(":") && !host.startsWith("[") && host.split(":").length > 2) { + return ipAddress(host); + } + const match = /^(?:\[([\da-f:.]+)\]|([^\s:/\\@?#[\],%]+))(?::(\d+))?$/.exec(host); + if (!match || match[0] !== host || (match[3] !== undefined && Number(match[3]) > 65535)) { + return ""; + } + if (match[1]) return match[1].includes(":") ? ipAddress(match[1]) : ""; + const name = (match[2] ?? "").replace(/\.$/, ""); + return name.length <= 253 && + name.split(".").every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/.test(label)) + ? name + : ""; +} + +/** OS HostName is deliberately not trusted; the shell may add it after verifying DNS. */ +export function buildAllowedHosts(input: { + tailscaleStatusJson?: string; + env: NetworkPolicyEnv; +}): ReadonlySet<string> { + const allowed = new Set(["localhost", "127.0.0.1", "::1"]); + const add = (raw: string) => { + const normalized = normalizeHost(raw); + if (normalized) allowed.add(normalized); + }; + try { + const status: unknown = JSON.parse(input.tailscaleStatusJson ?? "null"); + if (status && typeof status === "object" && "Self" in status) { + const self = status.Self; + if (self && typeof self === "object") { + if ("DNSName" in self && typeof self.DNSName === "string") { + const name = normalizeHost(self.DNSName); + if (name) { + add(name); + add(name.split(".")[0] ?? ""); + } + } + if ("TailscaleIPs" in self && Array.isArray(self.TailscaleIPs)) { + for (const ip of self.TailscaleIPs) { + if (typeof ip === "string" && ipAddress(ip)) add(ip); + } + } + } + } + } catch { + // Missing/malformed status must leave the loopback and explicit env policy intact. + } + for (const host of (input.env.DISPATCH_ALLOWED_HOSTS ?? "").split(",")) add(host.trim()); + if (input.env.DISPATCH_TAILSCALE_NAME) add(input.env.DISPATCH_TAILSCALE_NAME.trim()); + return allowed; +} + +export function checkHost( + hostHeader: HeaderValue, + allowed: ReadonlySet<string>, +): PolicyDecision<421> { + const host = hostHeader == null ? "" : normalizeHost(hostHeader); + return host && allowed.has(host) ? "ok" : { status: 421, reason: "Host is not allowed" }; +} + +export function checkOrigin( + originHeader: HeaderValue, + allowed: ReadonlySet<string>, +): PolicyDecision<403> { + if (originHeader == null) return "ok"; + const match = /^https?:\/\/([^/\\?#\s]+)$/i.exec(originHeader); + if (match?.[1] && match[0] === originHeader && checkHost(match[1], allowed) === "ok") { + try { + new URL(originHeader); + return "ok"; + } catch { + // Reject malformed URL authorities even if they resemble an allowed hostname. + } + } + return { status: 403, reason: "Origin is not allowed" }; +} + +/** Canonical authority for Vite's narrower parser; preserve ports and IPv6 brackets. */ +export function normalizeHostHeader(raw: string): string { + const host = normalizeHost(raw); + const port = /^(?:\[[^\]]+\]|[^:]+)(:\d+)$/.exec(raw)?.[1] ?? ""; + return `${host.includes(":") ? `[${host}]` : host}${port}`; +} + +/** Only the destination interface matters, NOT the remote peer's IP. */ +export function isAllowedLocalAddress( + address: string | undefined, + allowedAddresses: ReadonlySet<string>, +): boolean { + if (!address) return false; + // Dual-stack Node listeners report IPv4 destinations as ::ffff:a.b.c.d (or hex). + let normalized = normalizeHost(address); + if (normalized.startsWith("::ffff:")) { + const words = normalized.slice(7).split(":"); + if (words.length === 2) { + const value = + Number.parseInt(words[0] ?? "", 16) * 65536 + Number.parseInt(words[1] ?? "", 16); + normalized = [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join("."); + } + } + return allowedAddresses.has(normalized); +} + +/** Extract only actual IPs; neither env host extras nor a whole Tailscale subnet is trusted. */ +export function tailscaleSelf(json: string | undefined): { ips: string[]; hostName?: string } { + try { + const self = JSON.parse(json ?? "null")?.Self; + const ips = Array.isArray(self?.TailscaleIPs) + ? self.TailscaleIPs.filter( + (ip: unknown): ip is string => typeof ip === "string" && !!ipAddress(ip), + ) + : []; + return { + ips: ips.map((ip: string) => ipAddress(ip)), + ...(typeof self?.HostName === "string" ? { hostName: self.HostName } : {}), + }; + } catch { + return { ips: [] }; + } +} + +export interface DevNetworkPolicy { + readonly allowedHosts: ReadonlySet<string>; + readonly allowedAddresses: ReadonlySet<string>; +} + +/** Shared HTTP and HMR guard. Host precedes Origin, matching the backend. */ +export function checkDevRequest( + input: { localAddress: string | undefined; host: string | undefined; origin: string | undefined }, + policy: DevNetworkPolicy, +): PolicyDecision<403 | 421> { + if (!isAllowedLocalAddress(input.localAddress, policy.allowedAddresses)) { + return { status: 403, reason: "Local interface is not allowed" }; + } + const host = checkHost(input.host, policy.allowedHosts); + return host === "ok" ? checkOrigin(input.origin, policy.allowedHosts) : host; +} diff --git a/vite.config.ts b/vite.config.ts index 8ea9c54..4a74b9b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; +import { lookup } from "node:dns/promises"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -7,7 +8,119 @@ console.log("[spike] config module evaluated"); import { svelte } from "@sveltejs/vite-plugin-svelte"; import tailwindcss from "@tailwindcss/vite"; import { svelteTesting } from "@testing-library/svelte/vite"; -import { defineConfig, type Plugin } from "vitest/config"; +import { defineConfig, type Plugin, type UserConfig } from "vitest/config"; +import { + buildAllowedHosts, + checkDevRequest, + type DevNetworkPolicy, + normalizeHost, + normalizeHostHeader, + tailscaleSelf, +} from "./scripts/network-policy"; + +async function secureDevConfig(config: UserConfig, serving: boolean): Promise<UserConfig> { + // Build/test have no listener and need no subprocess or DNS effects. + if (!serving) return config; + const network = await detectDevNetworkPolicy(); + return { + ...config, + plugins: [devNetworkGuard(network), ...(config.plugins ?? [])], + server: { + ...config.server, + allowedHosts: [...network.allowedHosts], + // Safe reflection: our guard rejects disallowed Origins before Vite's CORS. + cors: { origin: true }, + }, + }; +} + +async function detectDevNetworkPolicy(): Promise<DevNetworkPolicy> { + let tailscaleStatusJson: string | undefined; + try { + tailscaleStatusJson = execFileSync("tailscale", ["status", "--self", "--json"], { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + console.warn( + "[network] Tailscale detection unavailable; using loopback and explicit env hosts", + ); + } + const allowedHosts = new Set( + buildAllowedHosts({ + ...(tailscaleStatusJson !== undefined ? { tailscaleStatusJson } : {}), + env: { + ...(process.env.DISPATCH_ALLOWED_HOSTS !== undefined + ? { DISPATCH_ALLOWED_HOSTS: process.env.DISPATCH_ALLOWED_HOSTS } + : {}), + ...(process.env.DISPATCH_TAILSCALE_NAME !== undefined + ? { DISPATCH_TAILSCALE_NAME: process.env.DISPATCH_TAILSCALE_NAME } + : {}), + }, + }), + ); + const self = tailscaleSelf(tailscaleStatusJson); + if (self.hostName) { + try { + const answers = await lookup(self.hostName, { all: true }); + if (answers.some(({ address }) => self.ips.includes(normalizeHost(address)))) { + const name = normalizeHost(self.hostName); + if (name) allowedHosts.add(name); + } + } catch { + // The OS hostname is not necessarily the MagicDNS name. Fail closed. + } + } + return { allowedHosts, allowedAddresses: new Set(["127.0.0.1", "::1", ...self.ips]) }; +} + +function devNetworkGuard(policy: DevNetworkPolicy): Plugin { + return { + name: "dispatch-dev-network-guard", + enforce: "pre", + configureServer(server) { + const check = (req: import("node:http").IncomingMessage) => + checkDevRequest( + { + localAddress: req.socket.localAddress, + host: req.headers.host, + origin: req.headers.origin, + }, + policy, + ); + server.middlewares.use((req, res, next) => { + const decision = check(req); + if (decision !== "ok") { + res.writeHead(decision.status, { "Content-Type": "text/plain" }); + res.end(decision.reason); + return; + } + // Vite's own check is case-sensitive and doesn't strip trailing dots. + req.headers.host = normalizeHostHeader(req.headers.host ?? ""); + next(); + }); + // Vite registers HMR before configureServer. Wrap (rather than prepend to) + // its listeners: EventEmitter otherwise continues into HMR after a denial. + const http = server.httpServer; + if (http) { + const upgrades = http.listeners("upgrade"); + http.removeAllListeners("upgrade"); + http.on("upgrade", (req, socket, head) => { + const decision = check(req); + if (decision !== "ok") { + socket.end( + `HTTP/1.1 ${decision.status} ${decision.status === 421 ? "Misdirected Request" : "Forbidden"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + return; + } + req.headers.host = normalizeHostHeader(req.headers.host ?? ""); + for (const upgrade of upgrades) upgrade.call(http, req, socket, head); + }); + } + }, + }; +} /** * SPIKE (plan 03) — the Svelte-runtime import map. @@ -105,16 +218,15 @@ function readBrowserHash(): string | null { // Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too // (jsdom + globals) so component tests run without extra config. -export default defineConfig({ +const config: UserConfig = { // svelteTesting() forces Svelte's `browser` resolve condition under vitest so // component render()/mount() works in jsdom (a plain test.resolve.conditions // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394). plugins: [tailwindcss(), svelte(), svelteTesting(), svelteRuntimeImportMap()], - // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN / - // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true` - // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set - // VITE_WS_URL to the backend's reachable host when browsing from another device.) - server: { port: 24204, host: true, allowedHosts: true }, + // Vite has one bind: destination-interface guards restrict HTTP + HMR to + // loopback / detected Tailscale IPs despite the dual-stack wildcard listener. + // Env host extras are additive and DISPATCH_BIND cannot widen this guard. + server: { port: 24204, host: true, allowedHosts: ["localhost", "127.0.0.1", "::1"] }, // SPIKE (plan 03): resolve the bare Svelte specifiers to svelte's SOURCE // files — stable dev URLs the import map points the plugin's bare imports // at (one shared runtime instance; no prebundle copy of svelte). @@ -171,7 +283,7 @@ export default defineConfig({ // rollup tree-shake entry exports no other chunk imports (leaving only // the bindings the app happens to use). Their exports must be the FULL // svelte API: a plugin may import bindings the app never uses. - preserveEntrySignatures: true, + preserveEntrySignatures: "strict", input: { main: fileURLToPath(new URL("./index.html", import.meta.url)), "svelte-api": svelteApiEntry, @@ -189,7 +301,11 @@ export default defineConfig({ globals: true, setupFiles: ["./vitest-setup.ts"], }, -}); +}; + +export default defineConfig(async ({ command }) => + secureDevConfig(config, command === "serve" && !process.env.VITEST), +); function getGitShortHash(): string { try { |
