diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..81ffc3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Build context exclusion list — keep the image lean and avoid bundling +# anything the runtime doesn't need. + +.git +.github +.vercel +.idea +.vscode +node_modules +tests +docs +examples +scripts +review.md +*.md +!README.md +Dockerfile +.dockerignore +.gitignore +.env +.env.local diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 441c8c6..176f569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,56 @@ jobs: - name: Run tests run: npm test + + # Validates the optional self-host path: image builds, container starts, + # HEALTHCHECK passes, /api/health responds, /api/divider returns SVG. + # Independent of the Node matrix above because the runtime inside the + # image is pinned by the Dockerfile (node:22-slim). + # + # No untrusted GitHub event data flows into any `run:` step — every + # shell variable below is initialized inside the script. + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + tags: profilekit:ci + load: true + + - name: Run container and verify /api/health + /api/divider + run: | + set -euo pipefail + docker run -d --name pkit -p 3000:3000 profilekit:ci + + # Wait up to ~30s for Docker's HEALTHCHECK (defined in the + # Dockerfile) to flip to "healthy". + for i in $(seq 1 30); do + status=$(docker inspect --format='{{.State.Health.Status}}' pkit 2>/dev/null || echo "unknown") + echo "attempt $i: health=$status" + if [ "$status" = "healthy" ]; then break; fi + sleep 1 + done + + # Independent verification from the host — don't trust HEALTHCHECK + # alone in case the probe itself is misconfigured. + body=$(curl -sf http://localhost:3000/api/health) + echo "$body" + echo "$body" | grep -q '"ok": true' + + # Real card endpoint — proves the route adapter is wired, not + # just /api/health. + ctype=$(curl -s -o /dev/null -w '%{content_type}' \ + 'http://localhost:3000/api/divider?style=line&width=400') + echo "divider ctype: $ctype" + echo "$ctype" | grep -q 'image/svg+xml' + + docker logs pkit + docker rm -f pkit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8d530f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# +# ProfileKit container image — optional self-hosted path. +# +# ProfileKit's primary deployment target is Vercel (api/[endpoint].js). +# This image is for anyone who wants to run ProfileKit on their own +# infrastructure instead. The Vercel path is unaffected. +# +# Zero npm dependencies (see package.json — no "dependencies" / +# "devDependencies" blocks), so there is no `npm install` step. The image +# is just a Node 22 runtime + the source. Builds in seconds. + +FROM node:22-slim + +# Pre-existing unprivileged user shipped by the node image. +WORKDIR /app + +# Copy only what the runtime needs. Tests / docs / build helpers stay out +# of the image — see .dockerignore for the exclusion list. +COPY package.json server.js ./ +COPY src/ ./src/ +COPY api/ ./api/ +COPY public/ ./public/ + +ENV NODE_ENV=production \ + PORT=3000 + +EXPOSE 3000 + +# Liveness / readiness probe. Node's built-in fetch (>=22) keeps the image +# dependency-free — no curl / wget install needed. +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +USER node + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 41c9125..8ff087a 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ A community gallery for sharing single-card presets and adopting others' designs ## About this project -**Currently implemented.** 28 SVG card endpoints (`/api/*`), 17 built-in themes plus gist-hosted custom palettes via `theme_url=`, five bundled variable fonts, `/api/stack` composition with namespaced child IDs, a live playground at [profilekit.vercel.app](https://profilekit.vercel.app), and an MCP wrapper at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp). Zero runtime dependencies, 30-minute CDN cache, deployed on Vercel. +**Currently implemented.** 28 SVG card endpoints (`/api/*`), 17 built-in themes plus gist-hosted custom palettes via `theme_url=`, five bundled variable fonts, `/api/stack` composition with namespaced child IDs, a live playground at [profilekit.vercel.app](https://profilekit.vercel.app), and an MCP wrapper at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp). Two deployment paths: **Vercel functions** (primary, `api/[endpoint].js`) and an **optional self-hosted Docker** image (`Dockerfile` + `server.js`) running the same handlers. Zero runtime dependencies, 30-minute CDN cache on the hosted instance. **Planned.** A single-card preset gallery at `/gallery` — adopt someone else's design URL as a starting point, then tweak parameters in the editor. Cross-agent preset compile (one preset → Claude Code, Cursor, Codex CLI configs). -**Design intent.** *No ranking, composable presentation.* Each card is a parameter-only URL — every visual property exposed as a query string so the same endpoint renders in a GitHub README, a dev.to bio, a Hashnode header, or a slide cover with no template forking. The gallery is for *adoption*, not voting: you start from someone else's preset and edit it; we do not show which preset is "most popular." Pure SVG with CSS / SMIL keeps animations alive inside GitHub's image proxy and removes the JavaScript attack surface. +**Design intent.** *No ranking, composable presentation.* Each card is a parameter-only URL — every visual property exposed as a query string so the same endpoint renders in a GitHub README, a dev.to bio, a Hashnode header, or a slide cover with no template forking. The gallery is for *adoption*, not voting: you start from someone else's preset and edit it; we do not show which preset is "most popular." Pure SVG with CSS / SMIL keeps animations alive inside GitHub's image proxy and removes the JavaScript attack surface. The self-hosted Docker path reuses the exact same handler files as the Vercel path via a thin `server.js` adapter — there is no "Docker-only" or "Vercel-only" code surface. -**Non-goals.** No ratings. No rankings. No leaderboards. No remix lineage / fork trees. No raster fallback for upload-only platforms (LinkedIn, Discord, X, Medium) — export to PNG yourself if you need one; we will not pretend the SVG works there. No tracking pixels, no per-view analytics. +**Non-goals.** No ratings. No rankings. No leaderboards. No remix lineage / fork trees. No raster fallback for upload-only platforms (LinkedIn, Discord, X, Medium) — export to PNG yourself if you need one; we will not pretend the SVG works there. No tracking pixels, no per-view analytics. **The self-hosted Docker mode does not replace the Vercel path** — it is purely additive for users who want to run ProfileKit on their own infrastructure. The hosted instance continues to be the default and is unaffected by any change to `server.js` or the Dockerfile. **Redacted.** None. @@ -663,13 +663,38 @@ A gallery of dimension presets for each context lives in [`examples/README.md`]( Copy any URL from the gallery, change the `name` / `subtitle` / `theme`, and drop it into the matching context. -## Self-Hosting +## Self-hosting + +Two supported paths. Pick whichever fits your infrastructure — both run the same handler code from `src/endpoints/`. + +### Path A — Vercel (default) 1. Fork this repo 2. Deploy to [Vercel](https://vercel.com/new) 3. Add environment variable: `GITHUB_TOKEN` — [create one here](https://github.com/settings/tokens) (no scopes needed for public data) 4. Done. Your endpoints are at `https://your-project.vercel.app/api/*` +### Path B — Docker (any container host) + +The repo ships a `Dockerfile` and a `server.js` adapter that turns the same handler files into a plain Node 22 HTTP server. No package install step (zero runtime deps), so the image builds in seconds. + +```bash +# Build once +docker build -t profilekit:local . + +# Run a single replica +docker run --rm -p 3000:3000 \ + -e GITHUB_TOKEN=ghp_... \ + profilekit:local +# → http://localhost:3000/api/divider?style=wave +``` + +For a real deployment (multiple replicas behind a load balancer), see [`examples/self-host/`](examples/self-host/) — `docker compose up --build --scale web=3` brings up three app replicas behind nginx round-robin. Each response carries an `X-ProfileKit-Instance` header so you can verify the LB is rotating across replicas. + +**Known limitation — token pool is per-process.** `src/common/github-token.js` keeps GitHub rate-limit state in process memory. With N replicas, each maintains its own pool independently, so a 429 on replica A doesn't tell replica B to skip the same token. For low-volume self-hosts it's invisible; for high-volume, either give each replica its own GitHub token (via `GITHUB_TOKENS=` comma list or `GITHUB_TOKEN_1..N` numbered form, see `.env.example`) or front the deployment with a shared rate-limit store (Redis) — out of scope for the bundled example. + +The Docker path is purely additive — the Vercel path keeps working unchanged. + ## Roadmap - **Now** — 28 card endpoints, 17 themes, playground at [profilekit.vercel.app](https://profilekit.vercel.app), MCP server at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp), curated picks in the Templates tab. diff --git a/examples/self-host/README.md b/examples/self-host/README.md new file mode 100644 index 0000000..a8a6bd3 --- /dev/null +++ b/examples/self-host/README.md @@ -0,0 +1,71 @@ +# Self-hosting ProfileKit (Docker) + +This is the optional self-hosted path. The primary deployment target is +still Vercel (`api/[endpoint].js`) — nothing here replaces that. + +## Quick start + +From this directory: + +```bash +docker compose up --build --scale web=3 +``` + +ProfileKit is now reachable at , with three app +replicas behind one nginx load balancer. + +To prove the load balancer is round-robining: + +```bash +for i in 1 2 3 4 5; do + curl -s -D - "http://localhost:8080/api/divider?style=wave" -o /dev/null \ + | grep -i x-profilekit-instance +done +``` + +The `X-ProfileKit-Instance` header rotates across the three replica +container IDs. + +## What's running + +| Service | Role | +|---|---| +| `web` × 3 | App replicas. Each runs `node server.js` from the repo-root Dockerfile. 128 MB memory + 0.5 CPU limit each — mirrors the Vercel function budget. | +| `lb` | nginx round-robin load balancer. Port 8080 (host) → 80 (LB) → 3000 (each replica). | + +## With GitHub-backed cards + +`/api/stats`, `/api/languages`, `/api/pin`, `/api/reviews` need a GitHub +token. Set one before bringing the stack up: + +```bash +export GITHUB_TOKEN=ghp_... +docker compose up --build --scale web=3 +``` + +The other 24 cards (hero, divider, wave, terminal, etc.) work without a +token. + +## Known limitation — token pool is per-process + +`src/common/github-token.js` stores rate-limit state (which token is +cooled-down for how long) in process memory. With N replicas, each +replica maintains its own pool state. A token that gets a 429 on +replica A will keep being tried on replica B and C until each replica +independently observes its own 429. + +For low-volume self-hosts that's invisible. For high-volume self-hosts +(many concurrent README embeds), point each replica at a separate +GitHub token via the `GITHUB_TOKENS=` or `GITHUB_TOKEN_1..N` form, or +front the deployment with a shared rate-limit store (Redis) — out of +scope for this example. + +## Scaling further + +```bash +docker compose up --build --scale web=10 +``` + +nginx picks up the new replicas automatically because the upstream uses +Docker's embedded DNS with per-request re-resolution (see +`nginx/nginx.conf`). diff --git a/examples/self-host/docker-compose.yml b/examples/self-host/docker-compose.yml new file mode 100644 index 0000000..caa91fc --- /dev/null +++ b/examples/self-host/docker-compose.yml @@ -0,0 +1,62 @@ +# docker-compose.yml — ProfileKit self-host example. +# +# Three app replicas behind one nginx load balancer. Each replica runs the +# same Dockerfile from the repo root; nginx round-robins across them and +# the X-ProfileKit-Instance response header reveals which replica answered. +# +# Run from this directory: +# docker compose up --build --scale web=3 +# curl -s -D - http://localhost:8080/api/divider?style=wave | grep -i instance +# (repeat — X-ProfileKit-Instance rotates across the three replicas) +# +# Resource limits mirror the Vercel function budget (128 MB / 10 s) so +# behavior under load matches production. + +services: + web: + build: + # Repo root — two directories up from this compose file. + context: ../.. + dockerfile: Dockerfile + image: profilekit:local + expose: + - "3000" # internal only — never published; the LB is the entry point. + environment: + # GitHub-backed cards (stats/languages/pin/reviews) need a token; pure + # cards (hero/divider/wave/terminal/...) work without one. Optional. + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + deploy: + replicas: 3 + resources: + limits: + cpus: "0.50" # half a core per replica + memory: "128M" # matches vercel.json's 128 MB function budget + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - profilekit-net + restart: unless-stopped + + lb: + image: nginx:1.27-alpine + depends_on: + - web + ports: + - "8080:80" # single public entry point. + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - profilekit-net + restart: unless-stopped + +networks: + profilekit-net: + driver: bridge diff --git a/examples/self-host/nginx/nginx.conf b/examples/self-host/nginx/nginx.conf new file mode 100644 index 0000000..45e5d63 --- /dev/null +++ b/examples/self-host/nginx/nginx.conf @@ -0,0 +1,49 @@ +# nginx.conf — load balancer in front of the ProfileKit replicas. +# +# Docker Compose runs the app behind this on port 8080 (host) → 80 (this +# container) → 3000 (each web replica). nginx re-resolves the `web` +# service name on every request so it round-robins across whatever +# replicas are currently up, including after `docker compose scale`. + +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + access_log /dev/stdout; + error_log /dev/stderr warn; + + # Docker's embedded DNS server. Resolving the service name `web` + # through it returns the A records of ALL replicas; combined with the + # variable form of proxy_pass below, nginx re-resolves and round- + # robins on every request rather than caching a single backend at + # boot. + resolver 127.0.0.11 valid=5s ipv6=off; + + server { + listen 80; + + # Surface that the request passed through the LB tier. + add_header X-LB nginx always; + + location / { + # Variable form forces per-request DNS resolution → round-robin + # across live `web` replicas. + set $backend "http://web:3000"; + proxy_pass $backend; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 3s; + # Slightly longer than the app's 10s maxDuration budget so + # nginx surfaces upstream timeouts cleanly rather than + # cutting the request off prematurely. + proxy_read_timeout 11s; + } + } +} diff --git a/package.json b/package.json index df4d55c..8ae3df5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "test": "node --test tests/*.test.js", - "check": "find api src scripts -name '*.js' -exec node --check {} +" + "check": "node --check server.js && find api src scripts -name '*.js' -exec node --check {} +" }, "license": "MIT" } diff --git a/server.js b/server.js new file mode 100644 index 0000000..57f01f5 --- /dev/null +++ b/server.js @@ -0,0 +1,144 @@ +// server.js — standalone HTTP server for ProfileKit self-hosting. +// +// ProfileKit's primary deployment target is Vercel (api/[endpoint].js). +// That file relies on Vercel's platform glue to (a) route /api/ to +// the dynamic handler and (b) provide Express-style req.query + +// res.status()/res.send(). +// +// To run ProfileKit as a plain container (or behind any reverse proxy / LB +// you control), this file reproduces that glue with Node 22's built-in +// http module — no npm dependencies, matching the zero-dep posture the +// rest of the project commits to. Vercel deploys are unaffected: they +// keep importing api/[endpoint].js. This file is purely additive. +// +// Security posture: the same ALLOWED set (CARDS keys + catalog + health) +// gates the dynamic `require` so a request like /api/../../etc/passwd +// cannot escape the endpoints directory. + +const http = require("node:http"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { CARDS } = require("./src/endpoints/catalog"); + +const ALLOWED = new Set([...Object.keys(CARDS), "catalog", "health"]); + +const PORT = Number(process.env.PORT || 3000); +// INSTANCE_ID lets a load balancer demo / multi-replica deploy reveal +// which replica answered (X-ProfileKit-Instance header). Defaults to the +// container hostname when present (Docker / Kubernetes). +const INSTANCE_ID = process.env.INSTANCE_ID || process.env.HOSTNAME || "local"; + +const PUBLIC_DIR = path.join(__dirname, "public"); +const STATIC_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".json": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", +}; + +// Adapt Node's ServerResponse to the Express-ish shape the endpoint +// handlers expect (they were written against Vercel's helper layer). +function adaptResponse(res) { + res.status = (code) => { + res.statusCode = code; + return res; + }; + res.send = (body) => { + res.end(body); + return res; + }; + return res; +} + +function serveStatic(req, res) { + let rel = req.url.split("?")[0]; + if (rel === "/") rel = "/index.html"; + // Path traversal guard: resolve the full target then assert containment + // inside PUBLIC_DIR. Rejects `/../../etc/passwd`, `/%2e%2e/foo`, etc. + const filePath = path.normalize(path.join(PUBLIC_DIR, rel)); + if (!filePath.startsWith(PUBLIC_DIR + path.sep) && filePath !== PUBLIC_DIR) { + res.statusCode = 403; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + return res.end("Forbidden"); + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + return res.end("Not found"); + } + res.setHeader( + "Content-Type", + STATIC_TYPES[path.extname(filePath)] || "application/octet-stream" + ); + res.end(data); + }); +} + +const server = http.createServer(async (req, res) => { + adaptResponse(res); + + // Stamp every response with the serving instance so curl against a load + // balancer visibly rotates across replicas (round-robin proof). + res.setHeader("X-ProfileKit-Instance", INSTANCE_ID); + + // Parse against a fixed origin — req.headers.host is untrusted in + // serverless / reverse-proxy environments and the endpoint handlers + // already build their own URL via parseSearchParams. + const url = new URL(req.url, "http://profilekit.local"); + const segments = url.pathname.split("/").filter(Boolean); + + // /api/ — reproduce Vercel's [endpoint] dynamic segment. + if (segments[0] === "api" && segments[1]) { + const endpoint = segments[1]; + if (!ALLOWED.has(endpoint)) { + res + .status(404) + .setHeader("Content-Type", "text/plain; charset=utf-8"); + return res.send(`Unknown endpoint: ${endpoint}`); + } + // Handlers consume params via parseSearchParams(req) which reads req.url + // directly, so url stays as-is. req.query mirrors what Vercel provides + // for handlers that prefer the parsed form (api/[endpoint].js uses it). + req.query = Object.fromEntries(url.searchParams); + req.query.endpoint = endpoint; + try { + const handler = require(`./src/endpoints/${endpoint}`); + return await handler(req, res); + } catch (err) { + res + .status(500) + .setHeader("Content-Type", "text/plain; charset=utf-8"); + return res.send(`Internal error: ${err.message}`); + } + } + + // Everything else → static playground assets (public/). + return serveStatic(req, res); +}); + +// Only auto-listen when run directly. When required from tests, the test +// drives `.listen(0)` itself on an ephemeral port. +if (require.main === module) { + server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`ProfileKit listening on :${PORT} (instance=${INSTANCE_ID})`); + }); + + // Graceful shutdown so the orchestrator can drain connections on + // scale-down without dropping in-flight requests. + for (const sig of ["SIGTERM", "SIGINT"]) { + process.on(sig, () => { + // eslint-disable-next-line no-console + console.log(`${sig} received — draining`); + server.close(() => process.exit(0)); + }); + } +} + +module.exports = { server, ALLOWED, PUBLIC_DIR }; diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..20eb8f2 --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,137 @@ +// Smoke tests for the standalone server.js adapter that powers the +// optional Docker self-host path. The same handler files are used by +// the Vercel path, so these tests cover the *adapter* (routing, ALLOWED +// gate, static serving, traversal guard, X-ProfileKit-Instance, graceful +// shutdown), not the card rendering itself (which has its own tests). +// +// Each test boots the server on an ephemeral port (0), exercises it via +// node:fetch, and closes it. No external mocking, no fixtures — the +// real HTTP stack and the real handlers. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const { server, ALLOWED, PUBLIC_DIR } = require("../server"); + +// Single shared listen — all tests reuse this port and the server.close() +// runs in test.after(). +let baseUrl; +test.before(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address(); + baseUrl = `http://localhost:${port}`; +}); +test.after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +test("ALLOWED set includes catalog and health alongside the 28 cards", () => { + assert.ok(ALLOWED.has("catalog"), "catalog must be allowlisted"); + assert.ok(ALLOWED.has("health"), "health must be allowlisted"); + assert.ok(ALLOWED.has("divider"), "card endpoints must be allowlisted"); + // Path-traversal-style names must NOT be in the set. + assert.ok(!ALLOWED.has("..")); + assert.ok(!ALLOWED.has("../etc/passwd")); +}); + +test("/api/health responds 200 with JSON and the instance header", async () => { + const res = await fetch(`${baseUrl}/api/health`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /application\/json/); + assert.ok( + res.headers.get("x-profilekit-instance"), + "every response must carry X-ProfileKit-Instance" + ); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.service, "profilekit"); +}); + +test("/api/divider responds 200 with image/svg+xml", async () => { + const res = await fetch(`${baseUrl}/api/divider?style=line&width=400`); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "image/svg+xml"); + const body = await res.text(); + assert.ok(body.startsWith(" { + const res = await fetch(`${baseUrl}/api/catalog`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /application\/json/); + const body = await res.json(); + assert.ok(body.cards && body.themes, "catalog must declare cards + themes"); +}); + +test("/api/ responds 404 — ALLOWED gate rejects arbitrary names", async () => { + const res = await fetch(`${baseUrl}/api/notreal`); + assert.equal(res.status, 404); +}); + +test("/api/../something cannot escape the endpoints directory", async () => { + // URL parsing normalizes /api/../foo → /foo at the WHATWG layer, so the + // server sees a non-/api/ path and falls through to static serving. + // Either way: dynamic require with an attacker-controlled name must not + // be reachable. Verified by asserting a 404 (no endpoint file) rather + // than a 500 (require error leaking the file system). + const res = await fetch(`${baseUrl}/api/..%2f..%2fetc%2fpasswd`); + assert.ok( + res.status === 404 || res.status === 400, + `expected 404/400 for traversal attempt, got ${res.status}` + ); +}); + +test("/ serves the playground index.html", async () => { + const res = await fetch(`${baseUrl}/`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /text\/html/); + const body = await res.text(); + assert.ok(body.length > 100, "playground index must be non-trivial"); +}); + +test("/robots.txt serves the static file with text/plain", async () => { + const res = await fetch(`${baseUrl}/robots.txt`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /text\/plain/); +}); + +test("static file 404 on missing asset", async () => { + const res = await fetch(`${baseUrl}/this-file-does-not-exist.html`); + assert.equal(res.status, 404); +}); + +test("path traversal on static path is rejected", async () => { + // The traversal guard normalizes the resolved path and asserts it stays + // under PUBLIC_DIR. A request like /../package.json must NOT leak the + // repo's package.json. + const res = await fetch(`${baseUrl}/%2e%2e/package.json`); + // Acceptable outcomes: + // 403 — path traversal guard rejected + // 404 — URL normalization at the WHATWG layer stripped the .. and the + // resulting path didn't match a public asset + // Unacceptable: 200 with the contents of package.json. + if (res.status === 200) { + const body = await res.text(); + assert.ok( + !body.includes('"name": "profilekit"'), + "traversal must not leak package.json" + ); + } else { + assert.ok( + res.status === 403 || res.status === 404, + `expected 403/404 for traversal, got ${res.status}` + ); + } +}); + +test("PUBLIC_DIR resolves to the repo's public/ directory", () => { + // Sanity check — if a refactor accidentally points PUBLIC_DIR at the + // repo root, the traversal guard becomes the only thing standing + // between an attacker and the entire source tree. + assert.ok( + PUBLIC_DIR.endsWith(path.join("ProfileKit", "public")) || + PUBLIC_DIR.endsWith("/public"), + `PUBLIC_DIR must end in /public, got ${PUBLIC_DIR}` + ); +});