From 21e6f4c2d5346228c518ea8aff6ba75a61c744a2 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:17 +0200 Subject: [PATCH 1/3] harden the reference backend: write bounds + CORS The @pcbjam/backend-example server had a 1 GiB body limit, no per-owner/per-lib quotas, bound to 0.0.0.0, and reflected any origin with credentials when CORS_ORIGIN is *. Bound the write surface (5 MiB body cap, per-owner lib and per-lib item quotas), bind 127.0.0.1 by default (opt in via HOST), and force credentials off for a wildcard CORS origin. Refactor main() into an exported buildApp() and add web/backend/test/security.test.ts (inject-based). Co-Authored-By: Claude Fable 5 --- web/backend/package.json | 6 +- web/backend/src/server.ts | 57 ++++++++++------ web/backend/src/user-libs.ts | 32 ++++++++- web/backend/test/security.test.ts | 109 ++++++++++++++++++++++++++++++ web/backend/vitest.config.ts | 10 +++ web/pnpm-lock.yaml | 3 + 6 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 web/backend/test/security.test.ts create mode 100644 web/backend/vitest.config.ts diff --git a/web/backend/package.json b/web/backend/package.json index b7cb0b71b..d746a2182 100644 --- a/web/backend/package.json +++ b/web/backend/package.json @@ -9,7 +9,8 @@ "ensure-libs": "tsx src/extract/ensure-example-libs.ts", "extract-libs": "tsx src/extract/extract-libs.ts", "build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@fastify/cors": "^9.0.1", @@ -20,6 +21,7 @@ "devDependencies": { "@types/node": "^22.10.5", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^3.2.6" } } diff --git a/web/backend/src/server.ts b/web/backend/src/server.ts index 22c71a2fa..66709ca3e 100644 --- a/web/backend/src/server.ts +++ b/web/backend/src/server.ts @@ -11,6 +11,7 @@ import { createReadStream } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; import cors from "@fastify/cors"; import Fastify from "fastify"; import { initServer } from "@ts-rest/fastify"; @@ -55,8 +56,18 @@ const PROJECT_DIR = path.resolve( process.env.PROJECT_DIR ?? "./project", ); const PORT = Number(process.env.PORT ?? 3060); +// Bind to loopback by default: this is an unauthenticated reference backend, so +// it shouldn't be reachable off-box unless an operator opts in via HOST. The +// owner namespace is a client-asserted hint, not an auth boundary — see +// user-libs.ts. +const HOST = process.env.HOST ?? "127.0.0.1"; const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:3048"; +// Write bounds: a real s-expr symbol/footprint body is KB. Cap the body parser +// well below the old 1 GiB so a single request can't write unbounded data. +// Overridable. +const MAX_ITEM_BYTES = Number(process.env.MAX_ITEM_BYTES ?? 5 * 1024 * 1024); + const TEXT_EXT = new Set([ ".kicad_pcb", ".kicad_sch", @@ -147,21 +158,19 @@ async function project(scope: string): Promise { }; } -async function main(): Promise { - const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 }); +export async function buildApp(): Promise { + const app = Fastify({ + logger: process.env.NODE_ENV !== "test", + bodyLimit: MAX_ITEM_BYTES, + }); + // CORS: an explicit origin list is always reflected with credentials. A `*` + // opt-in is different — reflect-any-origin WITH credentials is unsafe, so a + // wildcard forces credentials OFF. The invariant is enforced in code, not just + // documented in the config. + const wildcard = CORS_ORIGIN === "*"; await app.register(cors, { - // `true` REFLECTS the request origin (never the literal `*`), so it stays - // valid for the editor's credentialed fetches; allow-credentials is what - // lets the browser accept those responses (cookie-less callers unaffected). - // - // SECURITY INVARIANT: reflected-origin + allow-credentials is safe ONLY - // while this example backend holds no ambient credentials (no cookies, no - // sessions, no auth — which is its whole design; default origin is the - // explicit :3048, `*` is an operator opt-in). If any credentialed auth is - // ever added here, the `*` reflection mode MUST go — allow only explicit - // origin lists. - origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","), - credentials: true, + origin: wildcard ? true : CORS_ORIGIN.split(","), + credentials: !wildcard, }); app.get("/health", async () => ({ ok: true })); @@ -314,11 +323,19 @@ async function main(): Promise { }, ); - await app.listen({ port: PORT, host: "0.0.0.0" }); - app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR}`); + return app; +} + +async function main(): Promise { + const app = await buildApp(); + await app.listen({ port: PORT, host: HOST }); + app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR} on ${HOST}:${PORT}`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +// Only self-start when run directly (not when imported by a test). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/web/backend/src/user-libs.ts b/web/backend/src/user-libs.ts index e0cdc3edd..912ad2ff2 100644 --- a/web/backend/src/user-libs.ts +++ b/web/backend/src/user-libs.ts @@ -21,6 +21,12 @@ import { extForKind } from "./libs.js"; export const DEFAULT_OWNER = "default"; +// Write quotas: bound per-owner lib count and per-lib item count so an +// unauthenticated caller can't create unlimited libs/items; per-body size is +// capped by the server's bodyLimit (413). Overridable. +const MAX_LIBS_PER_OWNER = Number(process.env.MAX_LIBS_PER_OWNER ?? 100); +const MAX_ITEMS_PER_LIB = Number(process.env.MAX_ITEMS_PER_LIB ?? 1000); + export interface UserLibsConfig { /** Root for owner-namespaced writable user libs; null ⇒ writes disabled. */ dir: string | null; @@ -82,13 +88,18 @@ async function writeIndex(dir: string, idx: IndexFile): Promise { export class UserLibError extends Error { constructor( - public status: 400 | 404 | 409, + public status: 400 | 404 | 409 | 429, message: string, ) { super(message); } } +/** Count an owner's existing user libs (dirs with a readable index.json). */ +async function ownerLibCount(cfg: UserLibsConfig, owner: string): Promise { + return (await listUserLibs(cfg, owner)).length; +} + /** Create a user lib for an owner. Idempotent-ish: 409 if the id already exists. */ export async function createUserLib( cfg: UserLibsConfig, @@ -101,6 +112,12 @@ export async function createUserLib( if (await readIndex(dir)) { throw new UserLibError(409, `library "${id}" already exists`); } + if ((await ownerLibCount(cfg, owner)) >= MAX_LIBS_PER_OWNER) { + throw new UserLibError( + 400, + `library limit reached (max ${MAX_LIBS_PER_OWNER} per owner)`, + ); + } await fs.mkdir(dir, { recursive: true }); const idx: IndexFile = { type: "user", name, description: null, items: [] }; await writeIndex(dir, idx); @@ -185,10 +202,19 @@ export async function writeUserItem( const idx = await readIndex(dir); if (!idx) throw new UserLibError(404, `user library "${lib}" not found`); - await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8"); - const items = idx.items ?? []; const existing = items.find((i) => i.kind === kind && i.name === name); + // Only NEW items grow the lib — overwrites of an existing item are always + // allowed (the editor re-saves), so being at the cap never blocks edits. + if (!existing && items.length >= MAX_ITEMS_PER_LIB) { + throw new UserLibError( + 429, + `item limit reached (max ${MAX_ITEMS_PER_LIB} per library)`, + ); + } + + await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8"); + if (!existing) items.push({ kind, name }); idx.items = items; await writeIndex(dir, idx); diff --git a/web/backend/test/security.test.ts b/web/backend/test/security.test.ts new file mode 100644 index 000000000..d067f1f7b --- /dev/null +++ b/web/backend/test/security.test.ts @@ -0,0 +1,109 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/** + * Reference-backend hardening: write bounds (body size + per-owner/per-lib + * quotas) and CORS. The app is exercised via Fastify's app.inject() — no network + * listen. Env is set BEFORE the dynamic import so the module-level config + * (bodyLimit, quotas, CORS) captures the test values. + */ +const USER = "x-pcbjam-user"; + +async function tmpdir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("reference backend write bounds + CORS", () => { + let app: FastifyInstance; + let projectDir: string; + let userLibsDir: string; + + beforeAll(async () => { + projectDir = await tmpdir("pcbjam-proj-"); + userLibsDir = await tmpdir("pcbjam-userlibs-"); + await fs.writeFile(path.join(projectDir, "board.kicad_pcb"), "(kicad_pcb)"); + process.env.PROJECT_DIR = projectDir; + process.env.USER_LIBS_DIR = userLibsDir; + process.env.MAX_ITEM_BYTES = "2048"; + process.env.MAX_LIBS_PER_OWNER = "2"; + process.env.CORS_ORIGIN = "*"; + process.env.NODE_ENV = "test"; + vi.resetModules(); + const { buildApp } = await import("../src/server.js"); + app = await buildApp(); + await app.ready(); + }); + + afterAll(async () => { + await app?.close(); + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.rm(userLibsDir, { recursive: true, force: true }); + }); + + it("rejects an item body over the size cap with 413", async () => { + const oversized = "x".repeat(4096); // > MAX_ITEM_BYTES (2048) + const res = await app.inject({ + method: "PUT", + url: "/api/scopes/s/libs/anylib/items/symbol/Foo", + headers: { "content-type": "text/plain", [USER]: "sizer" }, + payload: oversized, + }); + expect(res.statusCode).toBe(413); + }); + + it("caps the number of libs an owner can create", async () => { + const mk = (name: string) => + app.inject({ + method: "POST", + url: "/api/scopes/s/libs", + headers: { "content-type": "application/json", [USER]: "quota" }, + payload: { name }, + }); + expect((await mk("lib-a")).statusCode).toBe(201); + expect((await mk("lib-b")).statusCode).toBe(201); + const third = await mk("lib-c"); // exceeds MAX_LIBS_PER_OWNER (2) + expect(third.statusCode).toBe(400); + expect(third.json().message).toMatch(/limit/i); + }); + + it("a wildcard CORS origin does NOT also allow credentials", async () => { + const res = await app.inject({ + method: "GET", + url: "/health", + headers: { origin: "https://other.example" }, + }); + // reflect-any-origin with allow-credentials is unsafe; the wildcard opt-in + // must force credentials OFF. + expect(res.headers["access-control-allow-credentials"]).not.toBe("true"); + }); +}); + +describe("reference backend CORS — explicit origin still allows credentials", () => { + it("a configured origin keeps credentials on", async () => { + const projectDir = await tmpdir("pcbjam-proj2-"); + await fs.writeFile(path.join(projectDir, "board.kicad_pcb"), "(kicad_pcb)"); + process.env.PROJECT_DIR = projectDir; + process.env.USER_LIBS_DIR = await tmpdir("pcbjam-userlibs2-"); + process.env.CORS_ORIGIN = "http://localhost:3048"; + process.env.NODE_ENV = "test"; + vi.resetModules(); + const { buildApp } = await import("../src/server.js"); + const app = await buildApp(); + await app.ready(); + try { + const res = await app.inject({ + method: "GET", + url: "/health", + headers: { origin: "http://localhost:3048" }, + }); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3048"); + } finally { + await app.close(); + await fs.rm(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/web/backend/vitest.config.ts b/web/backend/vitest.config.ts new file mode 100644 index 000000000..85a2f0c07 --- /dev/null +++ b/web/backend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +// The reference backend is a Fastify app; tests drive it via app.inject() in a +// plain node env (no network listen). +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7b9314984..f90a62b4c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 + vitest: + specifier: ^3.2.6 + version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4) pcbjam-shared: dependencies: From 74afb7b726207e1a8d5af967513e896448d313e8 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:17 +0200 Subject: [PATCH 2/3] 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 --- site/package-lock.json | 502 ++++++++++++++++++++++++++++++- site/package.json | 7 +- site/public/gerber-demo/boot.js | 16 +- site/src/pages/api/waitlist.ts | 56 +++- site/test/boot-overrides.test.ts | 65 ++++ site/test/waitlist.test.ts | 53 ++++ site/vitest.config.ts | 9 + 7 files changed, 690 insertions(+), 18 deletions(-) create mode 100644 site/test/boot-overrides.test.ts create mode 100644 site/test/waitlist.test.ts create mode 100644 site/vitest.config.ts diff --git a/site/package-lock.json b/site/package-lock.json index 40bf4585b..44de92e5e 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -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", diff --git a/site/package.json b/site/package.json index 3f4d17a8f..f45d04548 100644 --- a/site/package.json +++ b/site/package.json @@ -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" } } diff --git a/site/public/gerber-demo/boot.js b/site/public/gerber-demo/boot.js index b907c0def..ba21537b3 100644 --- a/site/public/gerber-demo/boot.js +++ b/site/public/gerber-demo/boot.js @@ -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