فهرست منبع

feat(telemetry): admin dashboard worker — scaffold + shared-password auth

New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and
bound read-only to the same D1 database. Serves a static frontend plus a JSON
API behind a shared password, on stats.getcodegraph.com.

Auth is the simplest thing that is actually safe for exactly two users: one
password in a secret, compared in constant time over SHA-256 digests, and an
HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year
expiry so you sign in once per browser. The cookie is a signed assertion, not a
lookup key — no session store. Its payload carries a fingerprint of the password
it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login
attempts are capped at 5/min per IP via a ratelimit binding.

Everything is deny-by-default: assets.run_worker_first routes every request
through the worker before the static-asset server sees it, so the dashboard
HTML, its JS, its CSS and the chart library are all behind the session check.
The login page is rendered inline by the worker rather than served from public/,
which leaves no "is this file public?" judgement calls in the asset directory.
Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing
secret fails closed rather than opening the dashboard.

scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway
`wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/
truncated cookies, open-redirect refusal, brute-force capping, and password
rotation invalidating live sessions.

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 ماه پیش
والد
کامیت
58251e7ef1

+ 7 - 0
telemetry-dashboard/.dev.vars.example

@@ -0,0 +1,7 @@
+# Copy to .dev.vars for local development (`npm run dev`) and so that
+# `wrangler types` includes both secrets in the generated Env.
+# The real values live only in the deployed secrets:
+#   wrangler secret put ADMIN_PASSWORD
+#   wrangler secret put SESSION_SECRET
+ADMIN_PASSWORD="dev-password"
+SESSION_SECRET="dev-session-secret-not-the-real-one"

+ 7 - 0
telemetry-dashboard/.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+.wrangler/
+.dev.vars
+# generated by `wrangler types` (npm run types) — includes .dev.vars keys
+worker-configuration.d.ts
+# copied out of node_modules by `npm run vendor`
+public/vendor/

+ 96 - 0
telemetry-dashboard/README.md

@@ -0,0 +1,96 @@
+# codegraph telemetry dashboard
+
+The private admin view behind `stats.getcodegraph.com`. Its sibling
+[`telemetry-worker/`](../telemetry-worker/) writes anonymous usage events into a D1 database;
+this worker reads them back and draws the charts. Two people use it, so the auth is
+deliberately the simplest thing that is actually safe: one shared password in a secret, and
+a long-lived signed cookie.
+
+This directory is in the public repo for the same reason the ingest worker is — the code
+that touches telemetry should be readable by the people it collects from. Nothing secret
+lives here: the password and the cookie-signing key are deployment secrets, and the D1
+database ID is an identifier, not a credential.
+
+## What is gated
+
+Everything except the login page and `robots.txt`. `assets.run_worker_first` is `true` in
+`wrangler.jsonc`, so Cloudflare hands *every* request to `src/index.ts` before the static
+asset server sees it — the dashboard HTML, its JS, its CSS and the chart library are all
+behind the session check, and a request without a valid cookie gets a redirect (pages) or a
+`401` (`/api/*`). The login page is rendered inline by the worker rather than served from
+`public/`, so the asset directory needs no "is this file public?" judgement calls.
+
+| Route | Auth | Notes |
+|---|---|---|
+| `GET /login` | public | Password form. Redirects to `/` if already signed in. |
+| `POST /login` | public | Rate-limited per IP; sets the session cookie on success. |
+| `POST /logout` | public | Clears the cookie. |
+| `GET /robots.txt` | public | `Disallow: /`. |
+| `GET /api/*` | required | JSON. `401` without a session. CG-12 adds the chart endpoints. |
+| everything else | required | Static assets from `public/`. `302 /login` without a session. |
+
+## How the session works
+
+- The password is compared in constant time, over SHA-256 digests so the operands are always
+  the same length and nothing about the secret leaks through timing.
+- The cookie is a signed assertion — `base64url(payload).base64url(HMAC-SHA256)` — not a
+  lookup key. There is no session store; a tampered payload fails the signature check.
+- `HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age` one year. You sign in once per browser
+  and it survives restarts.
+- The payload carries a fingerprint of the password it was minted against, so
+  **rotating `ADMIN_PASSWORD` signs everyone out** — that is the revocation story.
+- Login attempts are capped at 5/min per IP. Unlike the ingest worker, which never reads the
+  client IP at all, this one does — solely as a rate-limit key, never stored or logged.
+
+## Deploy
+
+Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom domain
+auto-provisions DNS + cert), and the D1 database from `telemetry-worker/` already created.
+
+```bash
+cd telemetry-dashboard
+npm install
+npx wrangler login                      # once
+
+npx wrangler secret put ADMIN_PASSWORD  # the shared password
+npx wrangler secret put SESSION_SECRET  # cookie-signing key, e.g. `openssl rand -base64 48`
+
+npm run deploy
+```
+
+Both secrets are required — the worker refuses every request if either is missing, so a
+half-configured deployment fails closed rather than becoming an open dashboard.
+
+Rotating either one is a `wrangler secret put` away. Rotating `SESSION_SECRET` invalidates
+outstanding cookies too, and is the right move if you think one leaked.
+
+Migrations belong to the writer, not to this worker: apply schema changes from
+`telemetry-worker/` (`npm run db:migrate`). D1 is read-only here.
+
+## Local dev & checks
+
+```bash
+cp .dev.vars.example .dev.vars   # placeholder secrets; also feeds `wrangler types`
+npm run check                    # vendor + wrangler types + tsc --noEmit + deploy --dry-run
+npm run dev                      # http://localhost:8787
+
+./scripts/smoke-auth.sh          # end-to-end auth suite against a throwaway `wrangler dev`
+```
+
+`smoke-auth.sh` is the regression net for the gate: it asserts that unauthenticated requests
+reach nothing (pages, API *and* static assets), that the cookie is persistent and correctly
+flagged, that flipped/truncated/forged cookies are all rejected, that brute force is capped,
+and that rotating the password invalidates existing sessions. Run it after touching
+`src/auth.ts` or the route table in `src/index.ts`. It needs a local D1 to answer
+`/api/health`, which it seeds itself from the ingest worker's migration.
+
+## Frontend
+
+Plain static files in `public/` — one HTML page, ES modules, no framework, no build step.
+Workers Static Assets serves them verbatim, so third-party libraries are copied out of
+`node_modules` into `public/vendor/` by `npm run vendor` (wired into `dev` and `deploy`).
+That keeps the version pinned by the lockfile, avoids a third-party origin at runtime, and
+lets the CSP stay `script-src 'self'`. `public/vendor/` is gitignored — it is build output.
+
+Visual conventions follow the rest of codegraph: flat and editorial, square corners, hairline
+rules, sentence-case headings, one oxblood accent, no tiny all-caps tracked labels.

+ 1577 - 0
telemetry-dashboard/package-lock.json

@@ -0,0 +1,1577 @@
+{
+  "name": "codegraph-telemetry-dashboard",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "codegraph-telemetry-dashboard",
+      "devDependencies": {
+        "chart.js": "^4.4.0",
+        "typescript": "^5.0.0",
+        "wrangler": "^4.36.0"
+      }
+    },
+    "node_modules/@cloudflare/kv-asset-handler": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+      "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/@cloudflare/unenv-preset": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+      "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "peerDependencies": {
+        "unenv": "2.0.0-rc.24",
+        "workerd": ">1.20260305.0 <2.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "workerd": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz",
+      "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-arm64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz",
+      "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz",
+      "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-arm64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz",
+      "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-windows-64": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz",
+      "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+      "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.9"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.3",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+      "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
+      "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
+      "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-freebsd-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
+      "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.2"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
+      "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
+      "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
+      "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
+      "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
+      "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
+      "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
+      "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
+      "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
+      "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
+      "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
+      "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
+      "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
+      "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
+      "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
+      "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
+      "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
+      "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
+      "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
+      "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.11.1"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-webcontainers-wasm32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
+      "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.2"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
+      "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
+      "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
+      "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+      "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@kurkle/color": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+      "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@poppinss/colors": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+      "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "kleur": "^4.1.5"
+      }
+    },
+    "node_modules/@poppinss/dumper": {
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+      "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@sindresorhus/is": "^7.0.2",
+        "supports-color": "^10.0.0"
+      }
+    },
+    "node_modules/@poppinss/exception": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+      "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@sindresorhus/is": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+      "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
+    "node_modules/@speed-highlight/core": {
+      "version": "1.2.17",
+      "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
+      "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/blake3-wasm": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+      "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/chart.js": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+      "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@kurkle/color": "^0.3.0"
+      },
+      "engines": {
+        "pnpm": ">=8"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/error-stack-parser-es": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+      "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/miniflare": {
+      "version": "4.20260722.1",
+      "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.1.tgz",
+      "integrity": "sha512-FJIg4omaCb2wwSyOeRosEdmVRi3JzGAOOH3pa3twmmtvWECl6BVZMIDwJbjByLKRyU+mrfk0M/n3oSiojFZvSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cspotcode/source-map-support": "0.8.1",
+        "sharp": "0.35.2",
+        "undici": "7.28.0",
+        "workerd": "1.20260722.1",
+        "ws": "8.21.0",
+        "youch": "4.1.0-beta.10"
+      },
+      "bin": {
+        "miniflare": "bootstrap.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+      "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+      "dev": true,
+      "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/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.35.2",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
+      "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.1.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.8.4"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.35.2",
+        "@img/sharp-darwin-x64": "0.35.2",
+        "@img/sharp-freebsd-wasm32": "0.35.2",
+        "@img/sharp-libvips-darwin-arm64": "1.3.1",
+        "@img/sharp-libvips-darwin-x64": "1.3.1",
+        "@img/sharp-libvips-linux-arm": "1.3.1",
+        "@img/sharp-libvips-linux-arm64": "1.3.1",
+        "@img/sharp-libvips-linux-ppc64": "1.3.1",
+        "@img/sharp-libvips-linux-riscv64": "1.3.1",
+        "@img/sharp-libvips-linux-s390x": "1.3.1",
+        "@img/sharp-libvips-linux-x64": "1.3.1",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
+        "@img/sharp-linux-arm": "0.35.2",
+        "@img/sharp-linux-arm64": "0.35.2",
+        "@img/sharp-linux-ppc64": "0.35.2",
+        "@img/sharp-linux-riscv64": "0.35.2",
+        "@img/sharp-linux-s390x": "0.35.2",
+        "@img/sharp-linux-x64": "0.35.2",
+        "@img/sharp-linuxmusl-arm64": "0.35.2",
+        "@img/sharp-linuxmusl-x64": "0.35.2",
+        "@img/sharp-webcontainers-wasm32": "0.35.2",
+        "@img/sharp-win32-arm64": "0.35.2",
+        "@img/sharp-win32-ia32": "0.35.2",
+        "@img/sharp-win32-x64": "0.35.2"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+      "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+      "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/unenv": {
+      "version": "2.0.0-rc.24",
+      "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+      "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "pathe": "^2.0.3"
+      }
+    },
+    "node_modules/workerd": {
+      "version": "1.20260722.1",
+      "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz",
+      "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "workerd": "bin/workerd"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "optionalDependencies": {
+        "@cloudflare/workerd-darwin-64": "1.20260722.1",
+        "@cloudflare/workerd-darwin-arm64": "1.20260722.1",
+        "@cloudflare/workerd-linux-64": "1.20260722.1",
+        "@cloudflare/workerd-linux-arm64": "1.20260722.1",
+        "@cloudflare/workerd-windows-64": "1.20260722.1"
+      }
+    },
+    "node_modules/wrangler": {
+      "version": "4.115.0",
+      "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.115.0.tgz",
+      "integrity": "sha512-+upG2VW66M1sjb43yzgUZ6Ss8iYpJ6+7F3U4GF8TY5EYd+08sYdS54d24AEwCnhuef3J9KlSPusqiqR9WYy1UA==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@cloudflare/kv-asset-handler": "0.5.0",
+        "@cloudflare/unenv-preset": "2.16.1",
+        "blake3-wasm": "2.1.5",
+        "esbuild": "0.28.1",
+        "miniflare": "4.20260722.1",
+        "path-to-regexp": "6.3.0",
+        "unenv": "2.0.0-rc.24",
+        "workerd": "1.20260722.1"
+      },
+      "bin": {
+        "cf-wrangler": "bin/cf-wrangler.js",
+        "wrangler": "bin/wrangler.js",
+        "wrangler2": "bin/wrangler.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.3"
+      },
+      "peerDependencies": {
+        "@cloudflare/workers-types": "^5.20260722.1"
+      },
+      "peerDependenciesMeta": {
+        "@cloudflare/workers-types": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.21.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+      "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/youch": {
+      "version": "4.1.0-beta.10",
+      "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+      "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@poppinss/dumper": "^0.6.4",
+        "@speed-highlight/core": "^1.2.7",
+        "cookie": "^1.0.2",
+        "youch-core": "^0.3.3"
+      }
+    },
+    "node_modules/youch-core": {
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+      "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/exception": "^1.2.2",
+        "error-stack-parser-es": "^1.0.5"
+      }
+    }
+  }
+}

+ 17 - 0
telemetry-dashboard/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "codegraph-telemetry-dashboard",
+  "private": true,
+  "description": "Password-gated admin dashboard over the codegraph telemetry D1 database (stats.getcodegraph.com)",
+  "scripts": {
+    "vendor": "node scripts/vendor-assets.mjs",
+    "dev": "npm run vendor && wrangler dev",
+    "deploy": "npm run vendor && wrangler deploy",
+    "types": "wrangler types",
+    "check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run"
+  },
+  "devDependencies": {
+    "chart.js": "^4.4.0",
+    "typescript": "^5.0.0",
+    "wrangler": "^4.36.0"
+  }
+}

+ 40 - 0
telemetry-dashboard/public/app.js

@@ -0,0 +1,40 @@
+/**
+ * Dashboard shell. CG-13 builds the Chart.js views on top of this; for now it
+ * just proves the gated API and the vendored chart library are both reachable.
+ */
+
+/** Every fetch goes through here so an expired session lands on /login instead
+ *  of failing silently mid-render. */
+export async function api(path) {
+  const response = await fetch(path, { headers: { accept: 'application/json' } });
+  if (response.status === 401) {
+    window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
+    throw new Error('session expired');
+  }
+  if (!response.ok) throw new Error(`${path} responded ${response.status}`);
+  return response.json();
+}
+
+function set(id, text, bad = false) {
+  const el = document.getElementById(id);
+  if (!el) return;
+  el.textContent = text;
+  el.classList.toggle('bad', bad);
+}
+
+set(
+  'chart-status',
+  typeof window.Chart === 'string' || window.Chart === undefined
+    ? 'Not loaded'
+    : `Chart.js ${window.Chart.version}`,
+  window.Chart === undefined,
+);
+
+try {
+  const health = await api('/api/health');
+  set('db-status', health.ok ? 'Connected' : 'Unavailable', !health.ok);
+  set('latest-event', health.database?.latest_event_day ?? 'No events yet');
+  set('latest-rollup', health.database?.latest_rollup_day ?? 'No rollups yet');
+} catch (err) {
+  set('db-status', String(err.message ?? err), true);
+}

+ 42 - 0
telemetry-dashboard/public/index.html

@@ -0,0 +1,42 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>codegraph telemetry</title>
+    <link rel="stylesheet" href="/styles.css" />
+  </head>
+  <body>
+    <header class="masthead">
+      <div>
+        <h1>codegraph telemetry</h1>
+        <p class="subtitle">Anonymous usage from the public engine, straight out of D1.</p>
+      </div>
+      <form method="post" action="/logout">
+        <button type="submit" class="secondary">Sign out</button>
+      </form>
+    </header>
+
+    <main>
+      <!-- CG-13 replaces this section with the Chart.js views. Until then it is a
+           live proof that the session gate, the D1 binding and the asset
+           pipeline all work end to end. -->
+      <section class="panel">
+        <h2>Connection</h2>
+        <dl class="facts">
+          <dt>Database</dt>
+          <dd id="db-status">Checking…</dd>
+          <dt>Latest event</dt>
+          <dd id="latest-event">—</dd>
+          <dt>Latest rollup</dt>
+          <dd id="latest-rollup">—</dd>
+          <dt>Chart library</dt>
+          <dd id="chart-status">Checking…</dd>
+        </dl>
+      </section>
+    </main>
+
+    <script src="/vendor/chart.umd.js"></script>
+    <script type="module" src="/app.js"></script>
+  </body>
+</html>

+ 100 - 0
telemetry-dashboard/public/styles.css

@@ -0,0 +1,100 @@
+/* Flat and editorial: square corners, hairline rules, sentence-case headings,
+   one oxblood accent. Matches getcodegraph.com. */
+
+:root {
+  --paper: #f7f6f2;
+  --ink: #16150f;
+  --muted: #56534a;
+  --oxblood: #7a201a;
+  --rule: #d8d5cb;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  padding: 24px;
+  background: var(--paper);
+  color: var(--ink);
+  font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: 16px;
+  line-height: 1.5;
+}
+
+.masthead {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid var(--rule);
+}
+
+h1 {
+  margin: 0 0 4px;
+  font-size: 22px;
+  font-weight: 600;
+}
+
+h2 {
+  margin: 0 0 12px;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.subtitle {
+  margin: 0;
+  color: var(--muted);
+}
+
+main {
+  max-width: 1080px;
+  margin: 24px 0 0;
+}
+
+.panel {
+  padding: 16px;
+  background: #fff;
+  border: 1px solid var(--rule);
+}
+
+.facts {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: 8px 24px;
+  margin: 0;
+}
+
+.facts dt {
+  color: var(--muted);
+}
+
+.facts dd {
+  margin: 0;
+}
+
+.facts dd.bad {
+  color: var(--oxblood);
+}
+
+button {
+  padding: 8px 14px;
+  font: inherit;
+  color: var(--paper);
+  background: var(--oxblood);
+  border: 1px solid var(--oxblood);
+  border-radius: 0;
+  cursor: pointer;
+}
+
+button.secondary {
+  color: var(--ink);
+  background: transparent;
+  border-color: var(--rule);
+}
+
+button.secondary:hover {
+  border-color: var(--ink);
+}

+ 211 - 0
telemetry-dashboard/scripts/smoke-auth.sh

@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+# End-to-end check of the auth gate against a local `wrangler dev`.
+#
+# Verifies the acceptance criteria for the gate: unauthenticated requests reach
+# nothing (pages, API, or static assets), a valid cookie reaches everything, and
+# a tampered cookie is rejected. Run it after touching src/auth.ts or the route
+# table in src/index.ts.
+#
+#   ./scripts/smoke-auth.sh
+set -uo pipefail
+
+cd "$(dirname "$0")/.."
+
+# Deliberately NOT $PORT: that is commonly already set to some other local dev
+# server, and the whole suite would then silently test the wrong app.
+DASH_PORT="${DASH_PORT:-8788}"
+BASE="http://127.0.0.1:${DASH_PORT}"
+PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
+JAR="$(mktemp -t cg-dash-jar)"
+LOG="$(mktemp -t cg-dash-log)"
+DEV_VARS_BACKUP="$(mktemp -t cg-dash-vars)"
+PASS=0
+FAIL=0
+
+cleanup() {
+  [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
+  # The rotation phase rewrites .dev.vars; always put the original back.
+  [[ -s "$DEV_VARS_BACKUP" ]] && cp "$DEV_VARS_BACKUP" .dev.vars
+  rm -f "$JAR" "$LOG" "$DEV_VARS_BACKUP"
+}
+trap cleanup EXIT
+
+# `curl -o /dev/null -w '%{http_code}'` plus the headers we care about.
+status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+body() { curl -s "$@"; }
+
+check() { # check <description> <expected> <actual>
+  if [[ "$2" == "$3" ]]; then
+    printf '  ok    %s\n' "$1"
+    PASS=$((PASS + 1))
+  else
+    printf '  FAIL  %s (expected %s, got %s)\n' "$1" "$2" "$3"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+contains() { # contains <description> <needle> <haystack>
+  if [[ "$3" == *"$2"* ]]; then
+    printf '  ok    %s\n' "$1"
+    PASS=$((PASS + 1))
+  else
+    printf '  FAIL  %s (missing %q in %.200q…)\n' "$1" "$2" "$3"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+lacks() { # lacks <description> <needle> <haystack>
+  if [[ "$3" != *"$2"* ]]; then
+    printf '  ok    %s\n' "$1"
+    PASS=$((PASS + 1))
+  else
+    printf '  FAIL  %s (found %q)\n' "$1" "$2"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+echo "Seeding local D1 from the ingest worker's migration…"
+npx wrangler d1 execute codegraph-telemetry --local \
+  --file=../telemetry-worker/migrations/0001_init.sql >/dev/null 2>&1
+
+echo "Starting wrangler dev on :${DASH_PORT}…"
+npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
+DEV_PID=$!
+READY=""
+for _ in $(seq 1 90); do
+  if [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
+  sleep 1
+done
+if [[ -z "$READY" ]]; then
+  echo "wrangler dev never came up on :${DASH_PORT} — log follows"
+  cat "$LOG"
+  exit 1
+fi
+
+echo
+echo "Unauthenticated — nothing but the login page and robots.txt"
+check "GET /               → 302 to login"   302 "$(status "$BASE/")"
+check "GET /index.html     → 302 to login"   302 "$(status "$BASE/index.html")"
+check "GET /styles.css     → 302 to login"   302 "$(status "$BASE/styles.css")"
+check "GET /app.js         → 302 to login"   302 "$(status "$BASE/app.js")"
+check "GET /vendor/chart   → 302 to login"   302 "$(status "$BASE/vendor/chart.umd.js")"
+check "GET /api/health     → 401"            401 "$(status "$BASE/api/health")"
+check "GET /api/session    → 401"            401 "$(status "$BASE/api/session")"
+check "GET /api/anything   → 401"            401 "$(status "$BASE/api/whatever")"
+check "GET /login          → 200"            200 "$(status "$BASE/login")"
+check "GET /robots.txt     → 200"            200 "$(status "$BASE/robots.txt")"
+contains "no data leaks in the 401 body" '"unauthorized"' "$(body "$BASE/api/health")"
+
+echo
+echo "Login page"
+LOGIN_HTML="$(body "$BASE/login")"
+contains "sentence-case heading"   "codegraph telemetry" "$LOGIN_HTML"
+contains "sentence-case label"     ">Password<"          "$LOGIN_HTML"
+contains "sentence-case button"    ">Sign in<"           "$LOGIN_HTML"
+lacks    "no uppercased labels"    "uppercase"           "$LOGIN_HTML"
+lacks    "no tracked-out labels"   "letter-spacing"      "$LOGIN_HTML"
+contains "label is normal size"    "font-size: 16px"     "$LOGIN_HTML"
+check "open redirect refused" "/" \
+  "$(body "$BASE/login?next=%2F%2Fevil.example" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
+check "same-origin next kept" "/api/health" \
+  "$(body "$BASE/login?next=%2Fapi%2Fhealth" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
+
+echo
+echo "Sign-in"
+check "wrong password        → 401" 401 \
+  "$(status -X POST "$BASE/login" -d "password=definitely-not-it" -d "next=/")"
+check "wrong password sets no cookie" "" \
+  "$(curl -s -D - -o /dev/null -X POST "$BASE/login" -d "password=nope" | grep -ci 'set-cookie' | sed 's/^0$//')"
+check "empty password        → 400" 400 "$(status -X POST "$BASE/login" -d "password=")"
+check "cross-origin post     → 400" 400 \
+  "$(status -X POST "$BASE/login" -H 'Origin: https://evil.example' -d "password=${PASSWORD}")"
+# One sign-in, then every cookie assertion reads the captured headers. Doing a
+# fresh POST per assertion would burn the login rate limit and 429 halfway down.
+SIGNIN="$(curl -s -D - -o /dev/null -c "$JAR" -X POST "$BASE/login" -d "password=${PASSWORD}" -d "next=/")"
+check    "correct password      → 302" "302" "$(printf '%s' "$SIGNIN" | head -1 | awk '{print $2}')"
+contains "cookie is HttpOnly"     "HttpOnly"         "$SIGNIN"
+contains "cookie is Secure"       "Secure"           "$SIGNIN"
+contains "cookie is SameSite=Lax" "SameSite=Lax"     "$SIGNIN"
+contains "cookie is ~1 year"      "Max-Age=31536000" "$SIGNIN"
+contains "cookie is site-wide"    "Path=/"           "$SIGNIN"
+
+COOKIE="$(grep cg_admin_session "$JAR" | awk '{print $NF}')"
+PAYLOAD="${COOKIE%%.*}"
+SIG="${COOKIE#*.}"
+
+# A persistent cookie carries a real expiry in the jar; a session cookie (gone
+# on browser restart) carries 0. This is the "survives a restart" criterion.
+JAR_EXPIRY="$(grep cg_admin_session "$JAR" | awk '{print $5}')"
+if [[ "$JAR_EXPIRY" -gt "$(( $(date +%s) + 300 * 86400 ))" ]]; then
+  check "cookie persists across browser restarts" "persistent" "persistent"
+else
+  check "cookie persists across browser restarts" "persistent" "session-only (expiry ${JAR_EXPIRY})"
+fi
+
+echo
+echo "Authenticated — the whole app"
+check "GET /            → 200" 200 "$(status -b "$JAR" "$BASE/")"
+check "GET /styles.css  → 200" 200 "$(status -b "$JAR" "$BASE/styles.css")"
+check "GET /app.js      → 200" 200 "$(status -b "$JAR" "$BASE/app.js")"
+check "GET /vendor/chart→ 200" 200 "$(status -b "$JAR" "$BASE/vendor/chart.umd.js")"
+check "GET /api/session → 200" 200 "$(status -b "$JAR" "$BASE/api/session")"
+check "GET /api/health  → 200" 200 "$(status -b "$JAR" "$BASE/api/health")"
+contains "health reads D1" '"ok":true' "$(body -b "$JAR" "$BASE/api/health")"
+check "GET /login while signed in → 302" 302 "$(status -b "$JAR" "$BASE/login")"
+check "unknown API route → 404" 404 "$(status -b "$JAR" "$BASE/api/nope")"
+check "POST to an API route → 405" 405 "$(status -b "$JAR" -X POST "$BASE/api/health")"
+
+echo
+echo "Tampering"
+# Mutate the FIRST signature character, not the last: base64url's final
+# character of a 32-byte tag carries only 4 significant bits, so flipping it is
+# sometimes a no-op on the decoded bytes and the test would pass vacuously.
+FLIPPED="${PAYLOAD}.$([[ "${SIG:0:1}" == 'A' ]] && echo B || echo A)${SIG:1}"
+check "flipped signature   → 401" 401 "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/api/health")"
+check "truncated signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}.${SIG:0:40}" "$BASE/api/health")"
+check "swapped payload     → 401" 401 \
+  "$(status -H "Cookie: cg_admin_session=$(printf '%s' '{"v":1,"iat":0,"exp":9999999999,"pw":"x"}' | base64 | tr -d '=' | tr '+/' '-_').${SIG}" "$BASE/api/health")"
+check "no signature        → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}" "$BASE/api/health")"
+check "garbage cookie      → 401" 401 "$(status -H 'Cookie: cg_admin_session=not-a-token' "$BASE/api/health")"
+check "empty cookie        → 401" 401 "$(status -H 'Cookie: cg_admin_session=' "$BASE/api/health")"
+check "tampered cookie on a page → 302 to login" 302 \
+  "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/")"
+
+echo
+echo "Sign-out"
+check "POST /logout → 302" 302 "$(status -X POST "$BASE/logout")"
+contains "logout clears the cookie" "Max-Age=0" \
+  "$(curl -s -D - -o /dev/null -X POST "$BASE/logout")"
+check "GET /logout  → 405" 405 "$(status "$BASE/logout")"
+
+echo
+echo "Rate limiting (6 attempts in a minute; the 6th should be capped)"
+LAST=""
+for _ in 1 2 3 4 5 6 7; do
+  LAST="$(status -X POST "$BASE/login" -d 'password=guess')"
+done
+check "brute force capped → 429" 429 "$LAST"
+
+echo
+echo "Password rotation (restarting with a different ADMIN_PASSWORD)"
+cp .dev.vars "$DEV_VARS_BACKUP"
+sed 's/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD="rotated-password"/' "$DEV_VARS_BACKUP" >.dev.vars
+kill "$DEV_PID" 2>/dev/null
+wait "$DEV_PID" 2>/dev/null
+npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
+DEV_PID=$!
+for _ in $(seq 1 90); do
+  [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]] && break
+  sleep 1
+done
+check "cookie from the old password → 401" 401 \
+  "$(status -H "Cookie: cg_admin_session=${COOKIE}" "$BASE/api/health")"
+check "old password no longer signs in → 401" 401 \
+  "$(status -X POST "$BASE/login" -d "password=${PASSWORD}")"
+check "new password signs in → 302" 302 \
+  "$(status -X POST "$BASE/login" -d "password=rotated-password")"
+
+echo
+printf '%s\n' "-----"
+printf '%d passed, %d failed\n' "$PASS" "$FAIL"
+[[ "$FAIL" -eq 0 ]]

+ 37 - 0
telemetry-dashboard/scripts/vendor-assets.mjs

@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+/**
+ * Copies third-party browser libraries out of node_modules into public/vendor/.
+ *
+ * Workers Static Assets are served verbatim — nothing in public/ goes through a
+ * bundler — so a library from npm has to be physically present there. Keeping
+ * it a copy step (rather than a checked-in blob or a CDN <script>) means the
+ * version is pinned by package.json, there is no third-party origin at runtime,
+ * and the CSP can stay `script-src 'self'`.
+ *
+ * public/vendor/ is gitignored; `npm run dev` and `npm run deploy` both run this
+ * first, so it is always present and always matches the lockfile.
+ */
+import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const vendorDir = join(root, 'public', 'vendor');
+
+const FILES = [
+  ['node_modules/chart.js/dist/chart.umd.js', 'chart.umd.js'],
+  ['node_modules/chart.js/LICENSE.md', 'chart.js-LICENSE.md'],
+];
+
+mkdirSync(vendorDir, { recursive: true });
+
+for (const [from, to] of FILES) {
+  const source = join(root, from);
+  if (!existsSync(source)) {
+    console.error(`vendor-assets: missing ${from} — run \`npm install\` first`);
+    process.exit(1);
+  }
+  copyFileSync(source, join(vendorDir, to));
+}
+
+console.log(`vendor-assets: copied ${FILES.length} file(s) into public/vendor/`);

BIN
telemetry-dashboard/src/auth.ts


+ 289 - 0
telemetry-dashboard/src/index.ts

@@ -0,0 +1,289 @@
+/**
+ * codegraph telemetry dashboard — stats.getcodegraph.com
+ *
+ * The private counterpart to `telemetry-worker/`: that one writes events into
+ * D1, this one reads them back for the two people who look at the numbers.
+ *
+ * Everything is deny-by-default. `assets.run_worker_first` is `true` in
+ * wrangler.jsonc, so the static-asset server never sees a request this file has
+ * not already authorised — the only unauthenticated surface is the login page,
+ * which the worker renders inline, and robots.txt.
+ *
+ * D1 is read-only here. Writes belong to the ingest worker's cron.
+ */
+
+import {
+  checkPassword,
+  clearedSessionCookie,
+  hasValidSession,
+  isSameOriginPost,
+  issueSession,
+  sessionCookie,
+} from './auth';
+import { renderLoginPage } from './login-page';
+
+const MAX_LOGIN_BODY_BYTES = 4 * 1024;
+
+const ROBOTS_TXT = 'User-agent: *\nDisallow: /\n';
+
+/**
+ * Security headers for every response. `styleNonce` is only passed for the
+ * inline-styled login page; asset-served pages link a stylesheet instead.
+ */
+function securityHeaders(styleNonce?: string): Record<string, string> {
+  const styleSrc = styleNonce ? `'self' 'nonce-${styleNonce}'` : "'self'";
+  return {
+    'content-security-policy': [
+      "default-src 'none'",
+      "script-src 'self'",
+      `style-src ${styleSrc}`,
+      "img-src 'self' data:",
+      "font-src 'self'",
+      "connect-src 'self'",
+      "form-action 'self'",
+      "base-uri 'none'",
+      "frame-ancestors 'none'",
+    ].join('; '),
+    'x-content-type-options': 'nosniff',
+    'x-frame-options': 'DENY',
+    'referrer-policy': 'no-referrer',
+    'cross-origin-opener-policy': 'same-origin',
+  };
+}
+
+function withSecurityHeaders(response: Response, styleNonce?: string): Response {
+  const out = new Response(response.body, response);
+  for (const [name, value] of Object.entries(securityHeaders(styleNonce))) {
+    out.headers.set(name, value);
+  }
+  return out;
+}
+
+/**
+ * Builds the response headers. Extras go through `new Headers(...)` rather than
+ * an object spread: spreading a `Headers` instance silently yields `{}`, and
+ * losing a `set-cookie` that way would be a very quiet bug.
+ */
+function headersWith(defaults: Record<string, string>, extra?: HeadersInit): Headers {
+  const headers = new Headers(defaults);
+  if (extra) {
+    for (const [name, value] of new Headers(extra)) headers.set(name, value);
+  }
+  return headers;
+}
+
+function html(body: string, init: ResponseInit & { nonce?: string } = {}): Response {
+  const { nonce, headers, ...rest } = init;
+  return withSecurityHeaders(
+    new Response(body, {
+      ...rest,
+      headers: headersWith(
+        {
+          'content-type': 'text/html; charset=utf-8',
+          // Never let a page render from cache after sign-out.
+          'cache-control': 'no-store',
+        },
+        headers,
+      ),
+    }),
+    nonce,
+  );
+}
+
+function json(body: unknown, init: ResponseInit = {}): Response {
+  const { headers, ...rest } = init;
+  return withSecurityHeaders(
+    new Response(JSON.stringify(body), {
+      ...rest,
+      headers: headersWith(
+        {
+          'content-type': 'application/json; charset=utf-8',
+          'cache-control': 'no-store',
+        },
+        headers,
+      ),
+    }),
+  );
+}
+
+function redirect(location: string, init: ResponseInit = {}): Response {
+  const { headers, status, ...rest } = init;
+  return withSecurityHeaders(
+    new Response(null, {
+      ...rest,
+      status: status ?? 302,
+      headers: headersWith({ location, 'cache-control': 'no-store' }, headers),
+    }),
+  );
+}
+
+/**
+ * Only same-origin absolute paths survive, so `?next=` can never become an open
+ * redirect. `//evil.example` and `/\evil.example` are protocol-relative URLs in
+ * a browser, not paths — hence the second character check.
+ */
+function safeNextPath(candidate: string | null): string {
+  if (!candidate || !candidate.startsWith('/')) return '/';
+  if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
+  return candidate;
+}
+
+function loginRedirect(url: URL): Response {
+  const next = `${url.pathname}${url.search}`;
+  const target = next === '/' ? '/login' : `/login?next=${encodeURIComponent(next)}`;
+  return redirect(target);
+}
+
+function nonce(): string {
+  const bytes = crypto.getRandomValues(new Uint8Array(16));
+  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+/** Best-effort brute-force cap on the shared password, keyed by client IP. */
+async function loginRateLimitOk(env: Env, request: Request): Promise<boolean> {
+  // Note for auditors: unlike the ingest worker — which never reads the client
+  // IP — this admin login does, purely as a rate-limit key. It is not stored,
+  // logged or forwarded anywhere.
+  const key = request.headers.get('cf-connecting-ip') ?? 'unknown';
+  try {
+    const { success } = await env.LOGIN_RATE_LIMITER.limit({ key });
+    return success;
+  } catch (err) {
+    // Fail open: a rate-limiter outage must not lock the maintainer out, and
+    // the password is still required either way.
+    console.error(JSON.stringify({ msg: 'login rate limiter unavailable', err: String(err) }));
+    return true;
+  }
+}
+
+async function handleLoginPage(env: Env, request: Request, url: URL): Promise<Response> {
+  const next = safeNextPath(url.searchParams.get('next'));
+  if (await hasValidSession(env, request)) return redirect(next);
+  const styleNonce = nonce();
+  return html(renderLoginPage({ next, nonce: styleNonce }), { nonce: styleNonce });
+}
+
+async function handleLoginSubmit(env: Env, request: Request): Promise<Response> {
+  if (!isSameOriginPost(request)) {
+    return new Response('bad request\n', { status: 400 });
+  }
+
+  const contentLength = Number(request.headers.get('content-length'));
+  if (Number.isFinite(contentLength) && contentLength > MAX_LOGIN_BODY_BYTES) {
+    return new Response('payload too large\n', { status: 413 });
+  }
+
+  let form: FormData;
+  try {
+    form = await request.formData();
+  } catch {
+    return new Response('bad request\n', { status: 400 });
+  }
+
+  const next = safeNextPath(String(form.get('next') ?? '/'));
+  const password = form.get('password');
+  const styleNonce = nonce();
+  const fail = (error: string, status: number): Response =>
+    html(renderLoginPage({ next, error, nonce: styleNonce }), { status, nonce: styleNonce });
+
+  if (!(await loginRateLimitOk(env, request))) {
+    return fail('Too many attempts. Wait a minute and try again.', 429);
+  }
+  if (typeof password !== 'string' || password.length === 0) {
+    return fail('Enter the password to continue.', 400);
+  }
+  if (!(await checkPassword(env, password))) {
+    return fail('That password is not right.', 401);
+  }
+
+  return redirect(next, { headers: { 'set-cookie': sessionCookie(await issueSession(env)) } });
+}
+
+/**
+ * Scaffold API. CG-12 hangs the real chart endpoints off `/api/*`; everything
+ * added there is gated by the same session check as this handler.
+ */
+async function handleApi(env: Env, url: URL): Promise<Response> {
+  if (url.pathname === '/api/session') {
+    return json({ authenticated: true });
+  }
+
+  if (url.pathname === '/api/health') {
+    try {
+      const batch = await env.DB.batch<{ day: string | null }>([
+        env.DB.prepare('SELECT max(day) AS day FROM events'),
+        env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
+      ]);
+      return json({
+        ok: true,
+        database: {
+          latest_event_day: batch[0]?.results[0]?.day ?? null,
+          latest_rollup_day: batch[1]?.results[0]?.day ?? null,
+        },
+      });
+    } catch (err) {
+      console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
+      return json({ ok: false, error: 'database unavailable' }, { status: 503 });
+    }
+  }
+
+  return json({ error: 'not found' }, { status: 404 });
+}
+
+/** Gated static assets: the dashboard shell, its JS, its CSS, the chart library. */
+async function serveAsset(env: Env, request: Request): Promise<Response> {
+  const asset = await env.ASSETS.fetch(request);
+  const out = withSecurityHeaders(asset);
+  // Behind a session, so it must never land in a shared cache.
+  out.headers.set('cache-control', 'private, no-cache');
+  out.headers.set('vary', 'cookie');
+  return out;
+}
+
+export default {
+  async fetch(request, env): Promise<Response> {
+    try {
+      const url = new URL(request.url);
+      const method = request.method;
+      const isRead = method === 'GET' || method === 'HEAD';
+
+      // --- unauthenticated surface: exactly these three routes ---------------
+      if (isRead && url.pathname === '/robots.txt') {
+        return new Response(ROBOTS_TXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
+      }
+      if (url.pathname === '/login') {
+        if (isRead) return await handleLoginPage(env, request, url);
+        if (method === 'POST') return await handleLoginSubmit(env, request);
+        return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET, POST' } });
+      }
+      if (url.pathname === '/logout') {
+        if (method !== 'POST') {
+          return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
+        }
+        if (!isSameOriginPost(request)) return new Response('bad request\n', { status: 400 });
+        return redirect('/login', { headers: { 'set-cookie': clearedSessionCookie() } });
+      }
+
+      // --- everything else needs a session -----------------------------------
+      const isApi = url.pathname === '/api' || url.pathname.startsWith('/api/');
+      if (!(await hasValidSession(env, request))) {
+        return isApi ? json({ error: 'unauthorized' }, { status: 401 }) : loginRedirect(url);
+      }
+
+      if (isApi) {
+        if (!isRead) {
+          return json({ error: 'method not allowed' }, { status: 405, headers: { allow: 'GET' } });
+        }
+        return await handleApi(env, url);
+      }
+
+      if (!isRead) {
+        return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET' } });
+      }
+      return await serveAsset(env, request);
+    } catch (err) {
+      console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
+      return new Response('internal error\n', { status: 500 });
+    }
+  },
+} satisfies ExportedHandler<Env>;

+ 120 - 0
telemetry-dashboard/src/login-page.ts

@@ -0,0 +1,120 @@
+/**
+ * The one page the worker renders itself.
+ *
+ * It is inline rather than a static asset because it is the only thing served
+ * without a session — keeping it here means the asset directory can stay
+ * entirely behind the gate, with no "is this file public?" judgement calls.
+ */
+
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+
+export interface LoginPageOptions {
+  /** Path to return to after a successful sign-in. Already validated same-origin. */
+  next: string;
+  /** Shown above the form when a previous attempt failed. */
+  error?: string;
+  /** CSP nonce for the inline stylesheet. */
+  nonce: string;
+}
+
+export function renderLoginPage({ next, error, nonce }: LoginPageOptions): string {
+  const errorBlock = error ? `\n      <p class="error" role="alert">${escapeHtml(error)}</p>` : '';
+  return `<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>Sign in — codegraph telemetry</title>
+    <style nonce="${nonce}">
+      :root {
+        --paper: #f7f6f2;
+        --ink: #16150f;
+        --oxblood: #7a201a;
+        --rule: #d8d5cb;
+      }
+      * { box-sizing: border-box; }
+      body {
+        margin: 0;
+        min-height: 100vh;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 24px;
+        background: var(--paper);
+        color: var(--ink);
+        font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        font-size: 16px;
+        line-height: 1.5;
+      }
+      main { width: 100%; max-width: 380px; }
+      h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
+      .subtitle { margin: 0 0 24px; color: #56534a; }
+      hr { border: 0; border-top: 1px solid var(--rule); margin: 0 0 24px; }
+      label { display: block; margin-bottom: 6px; }
+      input[type='password'] {
+        width: 100%;
+        padding: 9px 10px;
+        font: inherit;
+        color: var(--ink);
+        background: #fff;
+        border: 1px solid var(--rule);
+        border-radius: 0;
+      }
+      input[type='password']:focus {
+        outline: 2px solid var(--oxblood);
+        outline-offset: -2px;
+        border-color: var(--oxblood);
+      }
+      button {
+        margin-top: 16px;
+        width: 100%;
+        padding: 10px 12px;
+        font: inherit;
+        color: var(--paper);
+        background: var(--oxblood);
+        border: 1px solid var(--oxblood);
+        border-radius: 0;
+        cursor: pointer;
+      }
+      button:hover { background: #5f1914; border-color: #5f1914; }
+      .error {
+        margin: 0 0 16px;
+        padding: 9px 10px;
+        color: var(--oxblood);
+        background: #fff;
+        border: 1px solid var(--oxblood);
+      }
+      .footnote { margin: 24px 0 0; color: #56534a; font-size: 14px; }
+    </style>
+  </head>
+  <body>
+    <main>
+      <h1>codegraph telemetry</h1>
+      <p class="subtitle">This dashboard is private. Enter the shared password to continue.</p>
+      <hr />${errorBlock}
+      <form method="post" action="/login">
+        <input type="hidden" name="next" value="${escapeHtml(next)}" />
+        <label for="password">Password</label>
+        <input
+          id="password"
+          name="password"
+          type="password"
+          autocomplete="current-password"
+          required
+          autofocus
+        />
+        <button type="submit">Sign in</button>
+      </form>
+      <p class="footnote">You stay signed in on this browser for a year.</p>
+    </main>
+  </body>
+</html>
+`;
+}

+ 17 - 0
telemetry-dashboard/tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ES2022",
+    "moduleResolution": "Bundler",
+    "lib": ["ES2022"],
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noEmit": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "types": []
+  },
+  "include": ["src/**/*", "worker-configuration.d.ts"]
+}

+ 51 - 0
telemetry-dashboard/wrangler.jsonc

@@ -0,0 +1,51 @@
+// codegraph telemetry dashboard — see README.md.
+// Secrets are NOT configured here: ADMIN_PASSWORD and SESSION_SECRET are set
+// via `wrangler secret put`.
+{
+  "$schema": "node_modules/wrangler/config-schema.json",
+  "name": "codegraph-telemetry-dashboard",
+  "main": "src/index.ts",
+  "compatibility_date": "2026-07-28",
+
+  // Private admin surface. Same pattern as the ingest worker: a custom domain
+  // that auto-provisions DNS + cert from the getcodegraph.com zone, with
+  // workers.dev off so there is no second, undocumented way in.
+  "routes": [{ "pattern": "stats.getcodegraph.com", "custom_domain": true }],
+  "workers_dev": false,
+
+  "observability": { "enabled": true, "head_sampling_rate": 1 },
+
+  // `run_worker_first: true` is load-bearing. Without it Cloudflare serves a
+  // matching static asset BEFORE the worker runs, which would hand out the
+  // dashboard — and its data — to anyone who guesses a filename. With it, every
+  // request goes through src/index.ts and only an authenticated one is proxied
+  // on to ASSETS.
+  "assets": {
+    "directory": "./public",
+    "binding": "ASSETS",
+    "run_worker_first": true,
+    "html_handling": "auto-trailing-slash",
+    "not_found_handling": "none"
+  },
+
+  // The ingest worker's database, read-only from here. Migrations live with the
+  // writer: telemetry-worker/migrations/.
+  "d1_databases": [
+    {
+      "binding": "DB",
+      "database_name": "codegraph-telemetry",
+      "database_id": "5ed36dfb-d2d7-4e35-9e63-a1b99d0b1ed3"
+    }
+  ],
+
+  // Brute-force cap on the shared password, keyed by client IP. Two humans sign
+  // in roughly once a year each; 5/min is generous for a typo and useless for a
+  // guessing attack.
+  "ratelimits": [
+    {
+      "name": "LOGIN_RATE_LIMITER",
+      "namespace_id": "2001",
+      "simple": { "limit": 5, "period": 60 }
+    }
+  ]
+}