harden the marketing site: gate demo overrides, rate-limit + redact waitlist

- gerber-demo/boot.js honors the base/cdn/tag script-source overrides only on a
  loopback hostname, so a shipped page ignores them.
- /api/waitlist gains a best-effort per-IP/per-address rate limit and masks the
  submitter address in the no-key log line.
Adds vitest to the site: test/boot-overrides.test.ts (happy-dom) and
test/waitlist.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-16 12:46:17 +02:00
commit 74afb7b726
7 changed files with 690 additions and 18 deletions

502
site/package-lock.json generated
View file

@ -18,8 +18,10 @@
"@astrojs/check": "^0.9.9",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^26.1.0",
"happy-dom": "^15.11.6",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=22.12.0"
@ -2218,6 +2220,17 @@
"tailwindcss": "4.3.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@ -2227,6 +2240,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2397,6 +2417,131 @@
"ajv": "^6.12.3"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/kit": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
@ -2643,6 +2788,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astring": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
@ -2800,6 +2955,16 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@ -2810,6 +2975,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@ -2850,6 +3032,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@ -3126,6 +3318,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
@ -3537,6 +3739,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -3754,6 +3966,44 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/happy-dom": {
"version": "15.11.7",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz",
"integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^4.5.0",
"webidl-conversions": "^7.0.0",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/happy-dom/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/happy-dom/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
@ -4202,6 +4452,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
@ -4284,7 +4541,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4305,7 +4561,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4326,7 +4581,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4347,7 +4601,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4368,7 +4621,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4389,7 +4641,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4410,7 +4661,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4431,7 +4681,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4452,7 +4701,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4473,7 +4721,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4494,7 +4741,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4518,6 +4764,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
@ -6018,6 +6271,23 @@
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@ -6679,6 +6949,13 @@
"node": ">=20"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -6731,6 +7008,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@ -6741,6 +7025,13 @@
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -6792,6 +7083,19 @@
"node": ">=6"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@ -6878,6 +7182,13 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyclip": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.14.tgz",
@ -6912,6 +7223,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -7389,6 +7730,36 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"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"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-node/node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@ -7408,6 +7779,86 @@
}
}
},
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/volar-service-css": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
@ -7707,6 +8158,16 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@ -7741,6 +8202,23 @@
"node": ">=4"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View file

@ -11,7 +11,8 @@
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"astro": "astro"
"astro": "astro",
"test": "vitest run"
},
"dependencies": {
"@astrojs/mdx": "^6.0.3",
@ -24,7 +25,9 @@
"@astrojs/check": "^0.9.9",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^26.1.0",
"happy-dom": "^15.11.6",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^3.2.6"
}
}

View file

@ -40,6 +40,16 @@
var params = new URLSearchParams(location.search);
// The ?base= / ?cdn= / ?tag= overrides choose where the WASM bootstrap scripts
// are loaded from and are injected as classic <script src>, so they must be
// honored in LOCAL DEVELOPMENT ONLY — a shipped page ignores them. This is a
// static public/ asset (no `import.meta.env.DEV` substitution), so the check
// is a runtime hostname test.
var DEV_HOSTS = ["localhost", "127.0.0.1", "[::1]", "", "0.0.0.0"];
function devParam(name) {
return DEV_HOSTS.indexOf(location.hostname) !== -1 ? params.get(name) : null;
}
// KiCad paths baked into the WASM build (see web/standalone/src/wasm/constants.ts).
// KICAD_VERSION_DIR MUST match the deployed build's GetMajorMinorVersion() — the
// config we seed to suppress the first-run wizard is only read from THIS dir.
@ -116,10 +126,10 @@
}
function resolveBase() {
var override = params.get("base");
var override = devParam("base");
if (override) return Promise.resolve(override.replace(/\/+$/, ""));
var root = (params.get("cdn") || CDN_ROOT).replace(/\/+$/, "");
var tagParam = params.get("tag");
var root = (devParam("cdn") || CDN_ROOT).replace(/\/+$/, "");
var tagParam = devParam("tag");
var tagP = tagParam
? Promise.resolve(tagParam)
: fetchJson(root + "/manifest-latest.json").then(function (m) {

View file

@ -17,6 +17,45 @@ export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
/**
* Redact an address for logs: keep the domain (useful for debugging a
* misconfigured send) but mask the local-part so logs never hold the raw
* address.
*/
function maskEmail(email: string): string {
const at = email.indexOf('@');
if (at <= 0) return '***';
return `${email.slice(0, 1)}***@${email.slice(at + 1)}`;
}
/**
* Best-effort per-key rate limit an in-process sliding window that bounds
* bursts from one warm instance. NOT a complete defense on serverless (fresh
* instances don't share it), so production should ALSO front this with a shared
* store (Vercel KV / Upstash) or a CAPTCHA. Kept dependency-free so it runs.
*/
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX_PER_IP = 5;
const RATE_MAX_PER_EMAIL = 2;
const rateHits = new Map<string, number[]>();
function rateLimited(key: string, max: number, now: number): boolean {
const hits = (rateHits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
if (hits.length >= max) {
rateHits.set(key, hits);
return true;
}
hits.push(now);
rateHits.set(key, hits);
return false;
}
/** Client IP from the standard proxy headers (Vercel sets x-forwarded-for). */
function clientIp(request: Request): string {
const xff = request.headers.get('x-forwarded-for');
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
}
/**
* CORS headers when the request Origin is in the configured allowlist lets the
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
@ -86,9 +125,24 @@ export const POST: APIRoute = async ({ request }) => {
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
}
// Rate limit: bound per-IP and per-address submissions. Checked after
// validation so invalid emails don't consume budget.
const now = Date.now();
const ip = clientIp(request);
if (
rateLimited(`ip:${ip}`, RATE_MAX_PER_IP, now) ||
rateLimited(`email:${email}`, RATE_MAX_PER_EMAIL, now)
) {
return wantsJson
? json(429, { ok: false, error: 'rate_limited' }, cors)
: redirect('error');
}
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
if (!RESEND_API_KEY) {
console.warn(`[waitlist] RESEND_API_KEY not set — skipping send. email=${email} source=${source}`);
console.warn(
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
);
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
}

View file

@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* boot.js honors the ?base= / ?cdn= / ?tag= script-source overrides only on a
* loopback hostname; a shipped page must ignore them. Black-box test: run the
* REAL boot.js in a DOM at a given URL and check whether an external-origin
* <script> gets injected.
*/
const bootSrc = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "../public/gerber-demo/boot.js"),
"utf8",
);
const EXTERNAL = "https://external.example/x";
async function runBootAt(url: string): Promise<void> {
const hd = (window as unknown as {
happyDOM: { setURL(u: string): void; settings: { disableJavaScriptFileLoading: boolean } };
}).happyDOM;
// We assert on the injected <script src>, not on it actually loading — keep
// happy-dom from fetching the URL over the network.
hd.settings.disableJavaScriptFileLoading = true;
hd.setURL(url);
// The status scaffold boot.js's helpers look up (all null-guarded anyway).
document.body.innerHTML =
'<div id="status"><span id="status-text"></span><div id="progress-bar"></div></div>' +
'<div id="main-window"></div>';
// The default path fetches a CDN manifest — let it fail; we only assert on
// script injection, never on a successful boot.
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no network in test")));
(0, eval)(bootSrc);
await new Promise((r) => setTimeout(r, 20)); // let resolveBase().then(boot) settle
}
function externalScriptsInjected(): string[] {
return Array.from(document.querySelectorAll("script[src]"))
.map((s) => s.getAttribute("src") || "")
.filter((src) => /^https?:\/\//i.test(src));
}
describe("gerber-demo boot.js override gate", () => {
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = "";
document.querySelectorAll("script[src]").forEach((s) => s.remove());
});
it("ignores ?base= on a production origin — no external script injected", async () => {
await runBootAt(
"https://www.pcbjam.com/gerber-demo/index.html?base=" + encodeURIComponent(EXTERNAL),
);
expect(externalScriptsInjected().some((s) => s.includes("external.example"))).toBe(false);
});
it("still honors ?base= on localhost — the dev override is preserved", async () => {
await runBootAt(
"http://localhost:4321/gerber-demo/index.html?base=" + encodeURIComponent(EXTERNAL),
);
expect(externalScriptsInjected().some((s) => s.includes("external.example"))).toBe(true);
});
});

View file

@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
/**
* Waitlist route hardening: per-key rate limiting, and no raw address in logs on
* the no-key path.
*
* The route imports typed Astro env + the Resend SDK; both are stubbed so the
* handler runs in plain node. RESEND_API_KEY is left undefined so the no-key
* branch (the one that logs) is exercised.
*/
vi.mock("astro:env/server", () => ({
RESEND_API_KEY: undefined,
RESEND_SEGMENT_ID: undefined,
WAITLIST_FROM_EMAIL: "hello@pcbjam.com",
WAITLIST_ALLOWED_ORIGINS: "",
}));
vi.mock("resend", () => ({
Resend: class {
emails = { send: async () => ({}) };
contacts = { create: async () => ({}) };
},
}));
const { POST } = await import("../src/pages/api/waitlist.ts");
function post(email: string, ip: string): Promise<Response> {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: { "content-type": "application/json", "x-forwarded-for": ip },
body: JSON.stringify({ email }),
});
// Astro passes a full context; the handler only reads `request`.
return POST({ request } as unknown as Parameters<typeof POST>[0]) as Promise<Response>;
}
describe("waitlist route hardening", () => {
it("rate-limits a burst from one IP (429 after the per-IP cap)", async () => {
const ip = "203.0.113.9";
const statuses: number[] = [];
for (let i = 0; i < 7; i++) statuses.push((await post(`u${i}@ex.com`, ip)).status);
expect(statuses.filter((s) => s === 200).length).toBe(5); // RATE_MAX_PER_IP
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
});
it("does not write the raw email to logs in the no-key path", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await post("secret.person@example.com", "198.51.100.7");
const logged = warn.mock.calls.map((c) => c.join(" ")).join("\n");
expect(logged).not.toContain("secret.person@example.com"); // raw address masked
expect(logged).toContain("@example.com"); // domain kept for debugging
warn.mockRestore();
});
});

9
site/vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
// Node env by default (the waitlist route test). The boot.js DOM test opts into
// happy-dom per-file via a `// @vitest-environment happy-dom` pragma.
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
});