diff --git a/.env.example b/.env.example
index a1fa421..e2f57f4 100644
--- a/.env.example
+++ b/.env.example
@@ -50,3 +50,9 @@ LOG_LEVEL=info
# --- CORS ---
# CORS_ORIGINS=https://example.com,https://staging.example.com
+
+# --- Webhooks ---
+# Outbound webhooks are blocked from targeting private/loopback/link-local
+# hosts (SSRF guard). Set to true ONLY if you intentionally deliver to internal
+# hosts and trust every configured target.
+# WEBHOOK_ALLOW_PRIVATE_HOSTS=false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0ca1411..931198c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,7 +69,7 @@ jobs:
with:
node-version: '24'
cache: npm
- cache-dependency-path: ${{ steps.meta.outputs.dir }}/package-lock.json
+ cache-dependency-path: package-lock.json
- name: Log toolchain
run: |
@@ -86,9 +86,18 @@ jobs:
exit 1
fi
- - name: Install
- working-directory: ${{ steps.meta.outputs.dir }}
- run: npm ci || npm install --no-audit --no-fund
+ # Install from the workspace ROOT — the root package-lock.json is the
+ # single authoritative lockfile (per-package lockfiles were removed; they
+ # drifted out of sync and silently forced a non-deterministic `npm install`).
+ - name: Install (workspace root)
+ run: npm ci --include-workspace-root --workspaces
+
+ # Publish gate: don't ship if the packages' own tests are red. These live
+ # in the root tests/unit suite (cms-client.test.ts / site-kit.test.ts).
+ - name: Unit tests (publish gate)
+ env:
+ SESSION_SECRET: ci_release_secret_ci_release_secret_ci_release_secret_ci
+ run: npm run test:unit
- name: Build
working-directory: ${{ steps.meta.outputs.dir }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bcfbddc..aa38cdc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -51,6 +51,12 @@ jobs:
(cd packages/cms-client && npm run typecheck)
(cd packages/site-kit && npm run typecheck)
+ - name: Build (compile the shipped dist/ — NodeNext catches ESM issues)
+ run: |
+ npm run build
+ (cd packages/cms-client && npm run build)
+ (cd packages/site-kit && npm run build)
+
- name: Unit tests
run: npm run test:unit
@@ -62,4 +68,35 @@ jobs:
DB_PASSWORD: ''
DB_NAME: skelpo_test
SESSION_SECRET: ci_test_secret_ci_test_secret_ci_test_secret_ci_test
- run: npm run test:integration
+ run: |
+ set -o pipefail
+ npm run test:integration 2>&1 | tee /tmp/itest.log
+ # Tripwire: fail if the suite ran zero tests, or skipped ALL of them
+ # (e.g. the MySQL service was unreachable) — otherwise CI is green
+ # having verified nothing.
+ total=$(grep -oE 'tests [0-9]+' /tmp/itest.log | tail -1 | grep -oE '[0-9]+' || echo 0)
+ skipped=$(grep -oE 'skipped [0-9]+' /tmp/itest.log | tail -1 | grep -oE '[0-9]+' || echo 0)
+ if [ "${total:-0}" -eq 0 ]; then echo "::error::integration suite ran 0 tests"; exit 1; fi
+ if [ "${skipped:-0}" -eq "${total:-0}" ]; then echo "::error::all ${total} integration tests were skipped (MySQL unreachable?)"; exit 1; fi
+
+ - name: Boot smoke (compiled dist/server.js serves /healthz)
+ env:
+ DB_HOST: 127.0.0.1
+ DB_PORT: '3306'
+ DB_USER: root
+ DB_PASSWORD: ''
+ DB_NAME: skelpo_test
+ SESSION_SECRET: ci_test_secret_ci_test_secret_ci_test_secret_ci_test
+ PORT: '3137'
+ HOST: 127.0.0.1
+ run: |
+ node dist/server.js &
+ pid=$!
+ ok=0
+ for i in $(seq 1 30); do
+ if curl -fsS "http://127.0.0.1:3137/healthz" >/dev/null 2>&1; then ok=1; break; fi
+ sleep 1
+ done
+ kill "$pid" 2>/dev/null || true
+ if [ "$ok" -ne 1 ]; then echo "::error::compiled dist/server.js did not serve /healthz"; exit 1; fi
+ echo "boot smoke OK"
diff --git a/.gitignore b/.gitignore
index 81b015b..fd2fb1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ __perry_js_bundle.js
# Runtime data
uploads/
+uploads-*/
.cache/
*.skelpo-backup
diff --git a/CLAUDE.md b/CLAUDE.md
index cb2fc17..179e9d9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -148,19 +148,74 @@ implementing the interface — every byte path already routes through it.
## Perry-native facts
-- **`Bun.serve` is not implemented in Perry** as of Perry 0.5.1019. A
- `Bun` sentinel object exists but `Bun.serve` is `undefined`, and it's
- not on `globalThis`. The CMS server's Bun-detect fallback path
- therefore does NOT yet boot natively — verify before claiming it does.
-- For a Perry-native HTTP server today, use **Fastify** (Perry has a
- native Rust impl in `perry-stdlib`) — it compiles and runs cleanly.
- `node:http.createServer` compiles but the response body/headers don't
- propagate (returns `content-length: 0`); don't rely on it.
-- `@skelpo/cms-client` and `@skelpo/site-kit` **lack a `"perry":
- "./src/index.ts"` exports entry** (and don't ship `src/`), so the
- customer site can't cross-compile against them yet. `@perryts/mysql`
- is the reference for the right shape (`perry` export + `src/` in
- `files`). Adding this is the open blocker for compiling perry.land.
+Status on **Perry 0.5.1039**: the CMS **compiles to a native binary and
+boots** — migrate, seed, job worker, MySQL, and the HTTP listener bind are
+all native and verified. **HTTP request handling does NOT yet work**: every
+request throws `Symbol()` inside `app.fetch` and returns 500 (see the open
+runtime bugs below). So "compiles + boots + listens" ✅, "serves real
+responses" ❌ — not production-usable on Perry yet.
+
+Build it with **`npm run build:perry`** (do not call `perry` directly — see
+the invocation pitfall below). The old CLI subcommand was `perry build`;
+it's now **`perry compile`**.
+
+- **JS-only deps must be AOT-compiled (V8 runtime was removed).** Perry
+ can no longer evaluate JS at runtime, so any npm dep shipped as compiled
+ JS must be listed in **`perry.compilePackages`** (and
+ `perry.allow.compilePackages`) in `package.json`. We do this for
+ `hono` and `bcryptjs`. Without it: "JavaScript runtime (V8) support has
+ been removed."
+- **Invoke perry from its REAL path, not the `~/.cargo/bin/perry`
+ symlink.** The compiler finds its workspace (and the on-demand
+ "auto-optimize" step that builds + links the per-feature ext libs,
+ incl. the node:http server lib `libperry_ext_http.a`) by walking up
+ from `current_exe()` for `crates/perry-runtime`. Through the symlink
+ that resolves to `~/.cargo/bin` → workspace not found → only
+ `libperry_runtime.a` + `libperry_stdlib.a` get linked → node:http
+ server symbols are unresolved → the binary dies at the HTTP bind with
+ `TypeError: value is not a function`. **`PERRY_RUNTIME_DIR` does NOT
+ fix this.** `scripts/build-perry.sh` resolves the symlink for us.
+- **node:http server partially works.** `createServer`/`server.listen`/
+ `res.end`/`res.write` (sync + async) and the listener bind all work
+ natively. `IncomingMessage`: `req.method`/`req.url` work, **`req.on('data')`/
+ `req.on('end')` fire** (attach them **synchronously** in the createServer
+ callback — Perry fires them eagerly, so a deferred/`await`-ed registration
+ misses them and hangs). The old "body/headers don't propagate" note is
+ obsolete for the basics.
+- **OPEN Perry runtime bugs blocking serve** (file/track upstream; all
+ reproduced on 0.5.1039 in an isolated worktree build):
+ 1. **`req.headers` is `undefined`** on `IncomingMessage`. Workaround:
+ read **`req.rawHeaders`** (flat `[k,v,k,v,…]`, populated correctly)
+ and rebuild a `Headers`. `rawHeaders` is portable to Node too.
+ 2. **request body chunks arrive as `string`, not `Buffer`** — so
+ `Buffer.concat(chunks)` throws "list[0] … must be Buffer/Uint8Array".
+ Coerce each chunk (`Buffer.from(chunk)`) before concat.
+ 3. **THE blocker: `app.fetch` throws a bare `Symbol()` on every request**
+ (even header-less `GET /healthz`, a pure `c.json`), so all routes 500.
+ A trivial 1-route Hono app served through the *same* inline adapter
+ works (200), and `c.json`/`c.text`/`getCookie`/`c.req.header` all work
+ in isolation — so the trigger is somewhere in the CMS's full
+ middleware/route graph under compilation, not yet isolated. This is
+ the thing to chase next.
+- **Do NOT use `@hono/node-server`** under Perry. Its `serve()` now *binds*
+ (Perry #2533 fixed), but its request path throws and then crashes inside
+ its own catch handler (`e.name` on an undefined caught value). `server.ts`
+ should serve via an inline `node:http` adapter bridging
+ `(req,res)` ⇄ `app.fetch` instead — but note that adapter is **not yet
+ working end-to-end** because of bug 3 above. `@hono/node-server` is no
+ longer in `perry.compilePackages`.
+- **No `foo!++`** — a non-null assertion on an update expression trips
+ `U006` ("Update expression only supports identifiers and member
+ expressions"). Drop the `!` (or use `+= 1`).
+- **`Bun.serve` is still unimplemented** (a `Bun` sentinel exists, but
+ `Bun.serve` is `undefined` and not on `globalThis`) — the intended
+ Perry path is node:http, not Bun.
+- `@skelpo/cms-client` / `@skelpo/site-kit` still **lack a `"perry"`
+ exports entry** and don't ship `src/`. With `compilePackages` a
+ consumer can now compile their published JS directly, so this is no
+ longer a hard blocker — but cross-compiling the customer site against
+ them this way is **unverified**. `@perryts/mysql` remains the
+ reference shape (`perry` export + `src/` in `files`).
## Customer site (separate repo)
@@ -168,10 +223,11 @@ perry.land's Perry-native rewrite lives at `~/projects/perry-landing-skelpo`
→ pushed to `PerryTS/perryts.com` branch **`perry-native`**. Hono + JSX
+ Tailwind v4, depends on the two `@skelpo/*` packages from npm.
Deployed at **beta.perryts.com** via `deploy.sh`: cross-compile on a
-Linux worker (`root@84.32.98.120`, Perry 0.5.1018) → relay binary →
+Linux worker (`root@builder.perryts.com`, Perry 0.5.1018) → relay binary →
`root@webserver.skelpo.net` → pm2/nginx. Currently runs the
-`--node-fallback` (tsx) path because the Perry compile is blocked on
-the missing `perry:` exports above.
+`--node-fallback` (tsx) path; a native Perry compile of the customer
+site hasn't been re-attempted since the CMS-side findings above
+(`compilePackages` + real-binary invocation + inline node:http adapter).
## Benchmarks
diff --git a/docs/media-pipeline.md b/docs/media-pipeline.md
new file mode 100644
index 0000000..5c3c918
--- /dev/null
+++ b/docs/media-pipeline.md
@@ -0,0 +1,287 @@
+# Responsive media pipeline — design
+
+Status: **proposal** (2026-06-19). Owner: media subsystem.
+
+Goal: serve every image **at (near-)exactly the size and format the requesting
+device needs**, with **caching that is correct by construction**, fully
+integrated into the CMS origin — no generic pre-baked thumbnails, no reliance on
+a half-baked CDN image resizer. Because the CMS is (or will be) the native
+origin, it can do this better than a bolt-on CDN: it knows the source bytes, the
+focal point, *and* the layout.
+
+This doc is runtime-agnostic in its URL/cache/markup design; only the codec
+implementation differs between Node (today) and Perry-native (later).
+
+---
+
+## 1. Where we are today
+
+- Media rows already store `width`, `height`, `focalPoint` ({x,y} JSON), `sizeBytes`,
+ `mimeType`, and **mandatory `altText`** (`migrations/0001_initial.sql`,
+ `src/media/store.ts`).
+- `GET /api/v1/media/:id/raw` → original bytes (`Cache-Control: immutable`), or
+ 302 to a backend public URL via `mediaPublicUrl` (`src/routes/api/media.ts`).
+- `GET /api/v1/media/:id/url?w&h&format&quality&fit&gravity=focal` → a signed
+ **imgproxy** URL (external service; currently offline).
+- Site `Picture` (`verrano/site/src/ui.tsx`): with imgproxy off it points
+ `` straight at `/raw` — i.e. **ships the full original to every device**
+ (bad LCP/bandwidth/CWV). With imgproxy on it uses `@skelpo/site-kit`
+ `buildResponsiveImage` → imgproxy srcset.
+
+**Decision:** `/raw` becomes the **canonical original / fallback**. Page markup
+must reference sized, format-negotiated derivatives. Retire imgproxy as the
+responsive path; replace with an integrated derivative endpoint.
+
+---
+
+## 2. The core tension: "exact size" vs "cacheable"
+
+The server cannot know an image's *rendered* size unless the client tells it.
+Two mechanisms exist, neither complete alone:
+
+1. **`srcset`/`sizes` (universal).** Server offers a *menu* of widths; the
+ browser picks the smallest candidate satisfying layout × DPR. Works in every
+ browser. Rounds up to the nearest offered width (not pixel-exact).
+2. **Client Hints (`Sec-CH-Width`, `Sec-CH-DPR`).** Browser sends the exact
+ computed layout width + DPR in request headers → server returns the precise
+ size. This is the true "exactly what's needed" path — **Chromium only**
+ (Safari/Firefox do not send these). An *enhancement*, never the whole answer.
+
+**The "exact" trap:** honoring arbitrary requested widths makes every viewport a
+distinct derivative → unbounded CPU/disk and a trivial DoS (`?w=1,2,3,…`). The
+fix is **quantization**: snap any requested width *up* to a fixed ladder, clamp
+to source width and a max. Visually indistinguishable from exact; cache-bounded
+and safe. This single rule separates a robust system from a toy.
+
+Ladder (initial): `[320, 420, 540, 640, 768, 960, 1080, 1280, 1440, 1680, 1920,
+2240, 2560, 3200, 3840]`, clamped to `min(sourceWidth, 3840)`. Tune later
+(possibly perceptually spaced). DPR handled by the browser via `srcset`
+(picks a 2× candidate) or folded into the client-hint computation.
+
+---
+
+## 3. Architecture
+
+### 3.1 Content-addressed, immutable derivative URLs
+
+Every transform param — including a short hash of the source bytes — lives in
+the path, so the URL fully determines the bytes:
+
+```
+/api/v1/media/{id}/{srcHash8}/{w}x{h}-{fit}-{focal}-q{q}.{avif|webp|jpeg}
+# examples
+/api/v1/media/5/9f3a1c7e/824x0-cover-c50_40-q72.avif # focal crop, AVIF
+/api/v1/media/5/9f3a1c7e/640x0-fit-q80.webp # plain fit, WebP
+```
+
+- `srcHash8` = first 8 hex of SHA-256 of the source bytes (stored on the row).
+- `w`/`h`: target box; `0` = derive from the other + aspect. Always quantized.
+- `fit`: `cover` (crop) | `fit` (contain, no crop).
+- `focal`: `c{xx}_{yy}` (focal point ×100) when `fit=cover`, else omitted.
+- `q`: quality (or a `t{ssim}` target — see §3.6).
+- extension = output format.
+
+Because the URL is a pure function of the output bytes:
+
+```
+Cache-Control: public, max-age=31536000, immutable
+ETag: "{derivativeHash}" # strong; enables 304
+# NO Vary needed
+```
+
+Re-edit/replace the image → new `srcHash` → new URLs → automatic cache-bust, no
+purge API. **`Vary`-free is the whole point** — it's where generic CDN image
+resizers (Vary-on-Width / Vary-on-Accept) cache poorly in shared caches.
+
+### 3.2 First-request generation + persistent derivative cache
+
+1. Request hits the derivative URL.
+2. Look up the derivative by key in the cache (disk/S3, behind the existing
+ `MediaStorage` interface — new prefix, e.g. `derivatives/`).
+3. Hit → stream it (static-file fast path).
+4. Miss → decode source, transform, encode, **persist**, then stream.
+5. Concurrent misses for the same key coalesce (single-flight lock) so a cold
+ popular image isn't transformed N times.
+
+Derivatives are disposable — cheap to regenerate, optional LRU/size-cap eviction.
+
+### 3.3 Format negotiation via `` (not `Accept`+Vary)
+
+Emit ``, then `image/webp`, then a JPEG ``
+fallback. The browser declares its choice by *selecting a source*, so format is
+**in the URL** → still no `Vary`. AVIF is typically 30–50% smaller than JPEG at
+equal quality → direct LCP win. (Server still validates/limits which formats it
+will emit.)
+
+### 3.4 Focal-point art-direction — the structural advantage
+
+We already store `focalPoint`. The endpoint crops to the **exact aspect ratio
+the layout asks for**, centered on the focal point, at every breakpoint. A
+generic CDN cannot do this — it has no subject metadata. This is what makes
+output look hand-cropped everywhere. Default focal `{0.5,0.5}` when unset.
+
+### 3.5 `site-kit` markup generator
+
+Replace `buildResponsiveImage`'s imgproxy target with derivative URLs. It emits:
+
+- `` with AVIF/WebP/JPEG ``s,
+- a quantized `srcset` width ladder (clamped to source width),
+- a correct `sizes` per placement (caller-provided; CMS can compute from layout),
+- `width`/`height` attrs (kills CLS),
+- `loading=lazy` below the fold; `fetchpriority=high` + `` for the LCP image,
+- the LQIP placeholder as inline background (§3.7),
+- the mandatory `alt`.
+
+Public API sketch:
+
+```ts
+buildResponsiveImage({
+ cmsBase, mediaId, srcHash, sourceWidth,
+ aspectRatio?, // forces a focal cover-crop to this ratio
+ sizes, // e.g. "(max-width:768px) 100vw, 640px"
+ formats?: ['avif','webp','jpeg'],
+ quality?, ladder?, // overrides
+}) => ResponsiveImage
+imageHtml(img, { alt, loading, fetchPriority, className }) => string
+```
+
+### 3.6 Quality targeting (optional, phase 3+)
+
+Instead of a fixed `q`, target a perceptual quality (SSIM/butteraugli) or a
+target byte-size per derivative, per format. Yields smaller files at equal
+perceived quality. Cache key uses the *resolved* `q` so URLs stay immutable.
+
+### 3.7 LQIP / BlurHash
+
+At upload, generate a tiny blur placeholder (BlurHash string or a ~20px inline
+data-URI) and store it on the media row. Inline as the element background →
+instant first paint, zero layout shift while the real image loads.
+
+### 3.8 Optional Client-Hints "exact" upgrade (phase 3)
+
+Opt in with `Accept-CH: Sec-CH-Width, Sec-CH-DPR` (+ `Critical-CH`). On browsers
+that send them, a bare `GET /api/v1/media/:id` (or `/:id/auto`) computes
+`ceil(width × dpr)`, **quantizes**, picks format from ``/Accept, and
+**302-redirects to the immutable derivative URL**. Best of both: device-exact
+sizing *and* perfect downstream caching. Non-supporting browsers ignore this and
+use the `srcset` menu. The redirect response itself: short/no cache, `Vary:
+Sec-CH-Width, Sec-CH-DPR, Accept` (only on this thin redirect, never on bytes).
+
+---
+
+## 4. Schema changes
+
+Add to the `media` table (new migration):
+
+| column | type | purpose |
+|--------|------|---------|
+| `srcHash` | `CHAR(64)` | SHA-256 of source bytes → derivative URL hash + cache-bust |
+| `blurhash` | `VARCHAR(64)` NULL | LQIP placeholder |
+| `dominantColor` | `CHAR(7)` NULL | optional bg before blur paints |
+
+Backfill `srcHash`/`blurhash` for existing rows via a one-off job (read bytes,
+hash, blur). New uploads compute them inline.
+
+---
+
+## 5. `MediaTransformer` interface (runtime-agnostic)
+
+Mirror the storage-backend pattern (`MEDIA_BACKEND`). Routes/markup/cache never
+change between implementations.
+
+```ts
+interface TransformRequest {
+ source: Uint8Array;
+ width: number; height: number; fit: 'cover' | 'fit';
+ focal?: { x: number; y: number };
+ format: 'avif' | 'webp' | 'jpeg';
+ quality: number;
+}
+interface MediaTransformer {
+ transform(req: TransformRequest): Promise;
+ probe(bytes: Uint8Array): Promise<{ width: number; height: number; mime: string }>;
+ blurhash(bytes: Uint8Array): Promise;
+}
+```
+
+- **Node impl (now):** `sharp` (libvips) — decode/resize/encode AVIF/WebP/JPEG,
+ focal crop, blurhash. Ships today on the current Node deployment.
+- **Perry-native impl (later):** a `@perryts/image`-style lib wrapping a Rust
+ stack — `fast_image_resize` + `ravif`/`rav1e` (AVIF), `webp`, `mozjpeg`,
+ decoders via `image`/`zune-image`. This is the real engineering cost of "fully
+ native"; `sharp` is a native Node addon Perry can't run. The interface lets us
+ defer it without blocking the rest.
+
+Selected via `MEDIA_TRANSFORMER=sharp|perry` (default `sharp`).
+
+---
+
+## 6. Caching, correctness, security
+
+- Derivative bytes: `immutable` + strong `ETag`; honor `If-None-Match` → 304.
+- No `Vary` on byte responses (format/size are in the URL). `Vary` only on the
+ thin client-hint redirect.
+- **Anti-flood:** only quantized ladder widths accepted; clamp to source width +
+ hard max (3840) and max megapixels; allowlist formats; cap quality range.
+ Reject/normalize off-ladder params (302 to nearest, or 400). Optionally sign
+ derivative URLs (HMAC) so only CMS-emitted variants generate — prevents a
+ derivative-cache DoS.
+- Single-flight generation lock per key.
+- A dumb CDN or any HTTP cache in front "just works" because URLs are immutable —
+ the native origin does the smart part, the edge does distribution.
+
+---
+
+## 7. SEO
+
+- Stable, crawlable derivative + canonical original URLs.
+- Correct `Content-Type`; `width`/`height` attrs (CLS); modern formats (LCP).
+- Eager + `preload` the LCP image; `loading=lazy` the rest.
+- Mandatory descriptive `alt` (already enforced).
+- Image sitemap entries. All emitted by the one `site-kit` renderer → consistent.
+
+---
+
+## 8. Why integrated/native beats a CDN here
+
+- **One hop:** origin = transformer; no origin→CDN→resizer indirection.
+- **Metadata-aware:** focal point + layout `sizes` live in the CMS; a generic CDN
+ resizer has neither, so its crops/sizes are guesses.
+- **Markup + bytes from one place:** `srcset`/`sizes`/``/preload/LQIP
+ stay consistent with what the endpoint can actually produce.
+- **Immutable URLs** make every downstream cache correct for free.
+- **Policy control:** format/quality/quantization decided centrally, per image.
+
+The genuinely "further than everyone else" parts: **focal-exact art-direction at
+every breakpoint**, **content-addressed immutability**, and the
+**client-hints→immutable redirect** — all clean *only because* we own the origin.
+
+---
+
+## 9. Phased plan
+
+1. **Derivative endpoint + disk cache + quantization** (Node/`sharp`):
+ content-addressed immutable URLs, focal-aware crops, single-flight, 304s.
+ Add `srcHash`/`blurhash` columns + backfill job. CLI/API parity per repo rule.
+2. **`site-kit` ``/srcset generator** wired to it; flip site `Picture`
+ off `/raw`. → **~95% of the win, on Node, today.**
+3. **Client-Hints exact upgrade + LQIP inlining + LCP preload** (+ quality
+ targeting, optional).
+4. **Perry-native transformer** (`@perryts/image`) behind `MediaTransformer` —
+ drop-in once the codec lib exists.
+
+Steps 1–2 are high-leverage, low-risk, and shippable on the current Node prod.
+Step 3 is an incremental enhancement, not a prerequisite. Step 4 unblocks
+"fully native" and is gated on a Perry-linkable image codec stack.
+
+---
+
+## 10. Open questions
+
+- Derivative storage location/eviction policy (disk vs S3; size cap?).
+- Sign derivative URLs (HMAC) vs allowlist-only? (DoS posture.)
+- Exact width ladder + whether to space it perceptually.
+- Client-Hints: enable globally or per high-traffic template first.
+- Backfill strategy for the existing ~140 media rows (one-off job vs lazy on
+ first transform).
diff --git a/fable-audit.md b/fable-audit.md
new file mode 100644
index 0000000..ec70332
--- /dev/null
+++ b/fable-audit.md
@@ -0,0 +1,438 @@
+# Fable Audit — Skelpo CMS
+
+**Date:** 2026-07-04
+**Branch:** `fix/perry-runtime-compat` (with uncommitted working-tree changes)
+**Head:** `45989c4` (`fix(auth): lower bcrypt cost 12 → 10 for the perry runtime`)
+**Scope:** Full repository — `src/` (~16k LOC), `packages/` (`@skelpo/cms-client`, `@skelpo/site-kit`), `migrations/`, `tests/`, `.github/`, `scripts/`, `docs/`, config, and the current working diff.
+**Method:** Manual reading of the auth/authz/injection core plus six parallel deep-dive passes (auth & sessions; injection/XSS/SSRF/upload; authorization; correctness/concurrency; CI/build/deps/release; tests/docs/hygiene). Every finding was verified against the code; load-bearing claims (Perry fail-open path, cursor/`MyDateTime` serialization, CI exit-code masking, capability escalation, gitignore gap) were reproduced or re-read firsthand. Typecheck and the DB-free unit suite were executed.
+
+---
+
+## Executive summary
+
+Skelpo CMS is a well-architected, genuinely thoughtful codebase: the SQL layer is **uniformly parameterized** (no SQL injection found anywhere), media path handling is **safe by construction**, session/token/invite secrets use a CSPRNG, API tokens are stored hashed, there is **no default admin credential**, the permission core (`can()`) is carefully written, and the schema is well-indexed. It typechecks clean and its 57 unit tests pass.
+
+However, the audit surfaced a set of serious issues concentrated in five areas:
+
+1. **Output encoding / stored XSS.** Content bodies are rendered to HTML with no sanitization (`marked` and the TipTap renderer), reachable by low-privilege authors — a live stored-XSS on the public site.
+2. **Authorization holes.** `manage*` capabilities collapse into full admin (no allowlist on role/capability/user-role assignment); several read paths and the content `status` write skip the intended capability, exposing drafts, submission PII, and settings — some of it **unauthenticated**.
+3. **Caching correctness.** `invalidate()` is repeatedly called with cache keys instead of dependency keys (a silent no-op), the dependency graph leaks on eviction, and there is no TTL — so several surfaces serve **permanently stale** data.
+4. **CI integrity.** The integration test step can neither fail (an `&&/||` shell-chain masks the exit code) nor even detect a missing DB — so a green pipeline can verify almost nothing.
+5. **Data hygiene.** ~78 MB of a real, named customer's product photography (`uploads-verrano/`) sits untracked but **not** covered by `.gitignore` in what the README presents as an MIT public repo.
+
+Counts: **3 Critical, ~22 High, ~18 Medium, ~15 Low/Info.**
+
+### Fix these first (in order)
+
+1. **Add `uploads-verrano/` (and `uploads*/`) to `.gitignore` now** — a `git add -A` permanently publishes customer photos. (§11.1)
+2. **Sanitize rendered content HTML** in `@skelpo/site-kit` (`renderMarkdown`, `renderTipTap`) and block `javascript:` hrefs. (§3.1)
+3. **Restrict capability/role/user-role assignment** — allowlist caps, forbid granting `*` or caps the actor lacks, block editing built-in roles, block self-role escalation. (§2.1)
+4. **Fix the `test:integration` script** (exit code masking) and add a CI tripwire when the DB is unreachable. (§8.1)
+5. **Bump `hono` to ≥4.12.25** (free, in-range CVE fix) and refresh the lockfile. (§8.2)
+6. **Fix `invalidate()` dependency-key usage** and add a cache TTL. (§4.1–4.3)
+7. **Gate the leaky read paths** — admin content/detail, `/admin/forms/:slug`, and `GET /api/v1/settings`. (§2.3–2.5)
+8. **Disable the fake TOTP branch** (fail closed) until real verification ships. (§1.1)
+
+---
+
+## Severity legend
+
+- **Critical** — severe and reachable in a realistic configuration; fix before further production use.
+- **High** — serious; exploitable/triggerable under plausible conditions, or a correctness break that loses/leaks data.
+- **Medium** — real risk requiring specific conditions or elevated privilege.
+- **Low / Info** — hardening, hygiene, defense-in-depth, or documentation.
+
+Reachability is stated per finding. "Live" = affects the Node/Bun runtime as deployed today. "Latent" = not currently reachable but will activate under a stated condition (a Perry serve path, a custom role, an enrolled feature).
+
+---
+
+## 1. Authentication & session security
+
+**1.1 — HIGH (latent) — TOTP/2FA is non-functional and bypassable on both login paths.**
+`src/routes/api/auth.ts:80-91` gates on `user.totpVerified === 1` and then only checks `/^\d{6}$/.test(totpCode)` — **any six digits pass** (`000000` works); the code is never compared to `users.totpSecret` (the TODO admits it). The admin login (`src/admin/routes.tsx:101-129`) checks **no** TOTP at all. No code path currently sets `totpVerified = 1` (no enrollment route/CLI), so it is latent — but the docs present 2FA as a working feature, so an operator flipping the flag by hand turns 2FA into pure theater. `toPublicUser` even reports `totpEnabled` from that flag, so the UI would claim protection that doesn't exist.
+**Fix:** fail **closed** — block login with a hard error when `totpVerified = 1` until `src/auth/totp.ts` (HMAC-SHA1, ±1 step window, one-time step-reuse guard) exists; enforce it on the admin path too; add a test that a wrong code is rejected.
+
+**1.2 — HIGH — Session cookie `Secure` flag is off behind a TLS-terminating proxy; no HSTS.**
+`src/admin/routes.tsx:120` sets `secure: c.req.url.startsWith('https://')`. The inline `node:http` adapter always builds the URL as `http://…` (`src/server.ts`), and behind the documented nginx→pm2 deploy the app sees plaintext — so the admin `skelpoSession` cookie is issued **without `Secure`** and can leak over any http request. No `Strict-Transport-Security` header is set anywhere (`src/app.ts` adds only `X-Skelpo-Version`). The API login (`auth.ts:101`) is better (also honors `x-forwarded-proto`), but `/auth/refresh` (`auth.ts:172`) and the lang cookie (`routes.tsx:145`) share the weak check.
+**Fix:** derive scheme from `x-forwarded-proto` / a `TRUST_PROXY`/`config.siteUrl` setting for `Secure` on all auth cookies; emit HSTS.
+
+**1.3 — MEDIUM — `clientIp` trusts spoofable `X-Forwarded-For` with no trusted-proxy config.**
+`src/routes/api/_helpers.ts:19-25` takes the first `x-forwarded-for` / `x-real-ip` verbatim, falling back to the literal `'0.0.0.0'`; the socket address is never used. Consequences: the per-IP login limit (10/15 min, `ratelimit.ts`) is bypassed by rotating the header (password spraying); with no proxy every client shares the `'0.0.0.0'` bucket; the per-email limit (5/15 min, keyed on attacker-supplied email) lets an unauthenticated party **lock out a known admin email on demand** and flood `loginAttempts`; and `sessions.ip` / `formSubmissions.ip` audit fields are attacker-controlled.
+**Fix:** key rate-limiting on the real connection IP; only honor `X-Forwarded-For` from configured proxy hops.
+
+**1.4 — MEDIUM — Login timing enables user enumeration.**
+`auth.ts:65-77` and `routes.tsx:113`: an unknown email returns immediately (no bcrypt), a known email runs `bcrypt.compare` (~100-250 ms on Node, seconds on Perry). Response bodies match, but the timing gap distinguishes valid accounts.
+**Fix:** run a dummy bcrypt compare against a constant hash on the unknown-user path.
+
+**1.5 — MEDIUM — Session tokens stored in plaintext as the primary key.**
+`src/auth/sessions.ts:30,45` inserts and looks up the 256-bit token verbatim — unlike API tokens, which store `sha256(token)` (`tokens.ts`). Any read-only DB exposure (backup leak, replica, SQLi elsewhere) yields **live, replayable** session tokens.
+**Fix:** store `sha256(token)` as the PK and hash on lookup, matching the API-token design.
+
+**1.6 — MEDIUM — Password change does not invalidate other sessions; "log out everywhere" is dead code.**
+`src/routes/api/users.ts:114` updates `passwordHash` but never deletes sessions or revokes tokens; `deleteAllSessionsForUser` (`sessions.ts:55`) has **no caller** anywhere. After a suspected-compromise password reset, stolen cookies/tokens stay valid up to 30 days.
+**Fix:** call `deleteAllSessionsForUser` (and revoke tokens) on password change, preserving the current session if desired.
+
+**1.7 — MEDIUM — API token scopes are stored but never enforced.**
+`lookupToken` returns `scopes` and `middleware.ts:43` puts them on `auth.token.scopes`, but `can()` consults only role capabilities — no code reads `auth.token.scopes` (confirmed by grep). A "read-only" token wields the full privileges of its owner's role.
+**Fix:** intersect role capabilities with token scopes in the auth/permission path, or remove the scopes UI until enforced.
+
+**1.8 — LOW — 30-day absolute session TTL, no idle timeout, no rotation; `/auth/refresh` never revokes the old session.**
+`sessions.ts:6`, `auth.ts:165-176`. Sessions accumulate and a stolen cookie is usable for up to a month; refresh extends indefinitely without invalidating the prior token.
+**Fix:** add an idle timeout, lower the admin absolute lifetime, and delete the prior session on refresh.
+
+**1.9 — LOW — Weak password policy; bcrypt silently truncates at 72 bytes.**
+`src/auth/password.ts:16-21`: minimum 8, no complexity/breach check; the 200-char max is moot because bcrypt truncates at 72 bytes with no pre-hash, so long-passphrase entropy is lost.
+**Fix:** raise the admin minimum (≥12), add a breached-password check, and pre-hash (e.g. base64(sha256)) before bcrypt if long passphrases should count.
+
+**1.10 — LOW — `SESSION_SECRET` is required at boot but never used.**
+`src/config.ts:69` calls `required('SESSION_SECRET')`, but nothing in `src/` consumes it (cookies are unsigned bearer tokens; security rests on the random DB token, which is fine). The shipped placeholder passes the non-empty check, so operators may believe cookies are signed when they aren't.
+**Fix:** remove the unused secret, or enforce a real length/entropy check and document that cookies are unsigned.
+
+**1.11 — INFO — bcrypt cost 10 is acceptable but was lowered for all runtimes.**
+`password.ts:13` — cost 10 meets the OWASP floor; the Perry-perf rationale is sound. Note the change weakens Node/Bun hashes too (where it wasn't needed). Track raising back to ≥12 once Perry's bcrypt nears native speed. Existing hashes keep their embedded cost.
+
+**1.12 — INFO — CSRF rests solely on `SameSite=Lax`; logout is a state-changing GET.**
+No CSRF tokens or Origin/Referer checks exist (grep-confirmed). `Lax` does block cross-site POST, so the POST admin mutations are reasonably covered — but `GET /admin/logout` (`routes.tsx:131`) is a top-level-navigable forced-logout CSRF, and there's no defense-in-depth.
+**Fix:** make logout a POST; add a per-session CSRF token or explicit same-origin check to admin mutations.
+
+---
+
+## 2. Authorization & access control
+
+**2.1 — CRITICAL — `manage*` capabilities collapse into full admin; no allowlist on capability/role/user-role assignment.**
+`roleRoutes.patch('/:slug')` (`src/routes/api/users.ts:182-200`) writes `body.capabilities` verbatim with **no allowlist and no `isBuiltin` guard** (only DELETE checks `isBuiltin`). A holder of `manageRoles` can PATCH its **own** role to `{"global":["*"]}` (superadmin) or rewrite the built-in `admin`/`viewer` roles. Separately, a `manageUsers` holder can assign any `roleId`/`roleSlug` including `admin` on create (`users.ts:45-99`) or edit (`users.ts:101-124`), and can **reset any user's password** (`users.ts:114`) — i.e. mint or take over an admin account. `can()` grants everything for `global:['*']` (`check.ts:73,79`).
+Default seed only gives `admin` these caps, so it is **not exploitable out of the box** — but the product's custom-role feature makes `manage*` effectively equivalent to `*` for any delegated role, which is a total escalation.
+**Fix:** validate submitted capabilities against an allowlist; forbid granting `'*'` or any cap the actor lacks; block editing built-in roles and assigning a role ≥ the actor's privilege; forbid a user editing its own role's capabilities.
+
+**2.2 — HIGH — Content `status` is mass-assignable → publish without the `publish` capability, bypassing validation.**
+`PATCH /api/v1/content/:id` (`content.ts:291-292`) forwards the raw body into `updateContent`, which applies `patch.status` / `publishedAt` / `scheduledAt` (`writer.ts:237-239`); `POST /content` similarly honors `body.status`. Both gate only on `update`/`create`, never `publish`. A **contributor** (seeded `post:['read','create','updateOwn']`, no publish) can `PATCH {"status":"published"}` on its own post and push it live — skipping `validateFields`/`validateSeoForPublish` and leaving `publishedAt = NULL` (so it's excluded by date filters and mis-sorted). The admin writer (`routes.tsx:733-735`) whitelists fields and checks publish separately, so the API is strictly weaker.
+**Fix:** strip `status`/`publishedAt`/`scheduledAt` from the create/update field set; require an explicit `publish` check for any transition into `published`, running publish validation + `publishedAt` logic.
+
+**2.3 — HIGH — Admin content list & detail enforce only authentication, not `read`/`readDrafts`.**
+`GET /admin/content/:type` (`routes.tsx:441-471`) lists all statuses with `includeDrafts:true`, and `GET /admin/content/:type/:id` (`:611-618`) loads with drafts, both behind only `gate(c)`. Any authenticated user — a **viewer** (no `readDrafts`) or an **author** (no cap at all on the `doc` type) — can read every type's drafts and other users' unpublished rows. The API by-id path checks `readDrafts`; the admin path does not.
+**Fix:** check `read` and, for non-published rows, `readDrafts`/`readOthersDrafts` in both admin handlers.
+
+**2.4 — HIGH — Form-submission detail page has no capability check (PII exposure).**
+`GET /admin/forms/:slug` (`screens.tsx:762-855`) renders all submissions — names, emails, IPs — behind only `gate(c)`. The index page `/admin/forms` correctly gates on `viewSubmissions || manageForms`; the detail page does not. Any authenticated user (viewer/author/contributor) can enumerate `/admin/forms/` and read submitted PII.
+**Fix:** add the same `viewSubmissions || manageForms` gate the index uses.
+
+**2.5 — HIGH — `GET /api/v1/settings` and `/settings/:key` are unauthenticated (world-readable).**
+`src/routes/api/settings.ts:15-33` has no `requireAuth`/`can` gate; `getAllSettings()` returns every key. Anonymous clients can dump all settings — including `site.previewToken` (the maintenance-mode bypass, set at `routes.tsx:381`) and any secret an operator stored in the flat KV (SMTP creds, API keys, analytics tokens). Strictly weaker than the admin equivalent, which sits behind login.
+**Fix:** require auth, or maintain an explicit public-settings allowlist and expose only those keys.
+
+**2.6 — MEDIUM — `readOthersDrafts` is never enforced; `readDrafts` reads everyone's drafts.**
+`can()` resolves `readDrafts` via the generic `includes(action)` (`check.ts:95`) and ignores `ownerId`; the `readOthersDrafts` action is defined and seeded on admin but referenced by **no** route and no branch of `can()`. The API draft reads pass `row.authorId` but discard it. An **author** (has `readDrafts`, not `readOthersDrafts`) can read every other author's unpublished drafts via `GET /content?status=draft` and the by-id/slug/path endpoints.
+**Fix:** when `row.authorId !== userId`, require `readOthersDrafts`; make `can()` consult `ownerId` for these actions; scope the list query to the caller unless they hold `readOthersDrafts`.
+
+**2.7 — MEDIUM — Unauthenticated media item/raw/URL + IDOR enumeration.**
+`GET /media/:id`, `/media/:id/raw`, `/media/:id/url` (`media.ts:53-102`) perform no auth check (only the list at `:40` requires auth). IDs are sequential; there is no per-asset ACL. Anyone can enumerate `/api/v1/media//raw` and download every uploaded asset, including images attached to unpublished content.
+**Fix:** if media is private, require auth on item/raw/url; if public-CDN, use unguessable keys and document the public contract. At minimum make gating consistent.
+
+**2.8 — LOW — `viewSubmissions` is dead on the API; seeded `editor` cannot moderate.**
+The submissions API (`forms.ts:129,150,162`) gates on `manageForms`, never `viewSubmissions`; the admin moderation POST (`screens.tsx:860`) does the same. The seeded `editor` carries `viewSubmissions` (not `manageForms`) and CLAUDE.md says editors moderate submissions — so the mark-spam/delete actions silently no-op for the exact role designed to use them.
+**Fix:** accept `viewSubmissions` for read + moderation; reserve `manageForms` for definition CRUD.
+
+**2.9 — INFO — Per-type caps shadow the `'*'` type entry instead of merging.**
+`check.ts:81`: `types[typeSlug] ?? types['*']` — a specific entry fully replaces the wildcard (no union). Intentional for the `editor.form` restriction, but a footgun: `{'*':['read'], post:['create']}` silently loses `read` on `post`. Over-denies (safe), not over-allows.
+
+**2.10 — INFO — Content-type schemas are served publicly.**
+`GET /types` and `/types/:slug` (`types.ts:19-28`) are unauthenticated, revealing every type's full field schema. Acceptable if intended for the frontend; noted for completeness.
+
+**Positive:** the ownership core is correct — `can()` checks the base action before the `*Own` short-circuit (`check.ts:86-92`), so holding `updateOwn` does not grant edit on arbitrary rows, and the content write routes pass `row.authorId`. The wildcard `*` handling is sound.
+
+---
+
+## 3. Injection, output encoding & SSRF
+
+**3.1 — CRITICAL — Content bodies rendered to HTML with no sanitization (stored XSS on the public site).**
+`@skelpo/site-kit`'s `renderMarkdown()` (`packages/site-kit/src/markdown.ts`) calls `marked.parse()` with no sanitizer (site-kit's only dependency is `marked`, which passes raw inline HTML through). Content bodies are writable by non-admin roles (**editor** has create/update/publish on all non-form types; **author**/**contributor** author posts). An author publishing a body containing `` yields stored XSS for every public visitor of the consuming site. The TipTap renderer compounds it: `renderTipTap` (`packages/site-kit/src/richtext.ts:38-41`) emits `` where `attrEsc` only escapes `&`/`"` and does **not** validate the scheme — a `javascript:` link href renders a clickable XSS.
+**Fix:** sanitize the rendered HTML server-side (DOMPurify/`sanitize-html`) with a strict allowlist, or disable raw-HTML passthrough in `marked`; allowlist URL schemes (`http`/`https`/`mailto`/relative) and drop `javascript:`/`data:`/`vbscript:`.
+
+**3.2 — HIGH — Media upload trusts client `Content-Type`, served inline same-origin (stored XSS / arbitrary hosting).**
+Upload (`media.ts:106-148`) stores `file.type` verbatim with no MIME allowlist and no magic-byte check; `isImage` uses `startsWith('image/')`, accepting `image/svg+xml`. `GET /api/v1/media/:id/raw` (`:61-76`) is **unauthenticated**, streams with `Content-Type: m.mimeType` (client-chosen), no `Content-Disposition`, `Cache-Control: immutable`. With the default `MEDIA_BACKEND=local` (`publicUrl()===null`) it is served **same-origin as the admin**, so an SVG/HTML upload executes JS on the CMS origin and hosts arbitrary active content on the trusted domain. (Needs `manageMedia` — admin by default, but grantable to custom roles, and the unauthenticated hosting is dangerous regardless.)
+**Fix:** enforce a server-side MIME/extension allowlist validated against sniffed magic bytes; never echo the client MIME for risky types; send `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` on `/raw`; store/serve SVG as `text/plain` or sanitize.
+
+**3.3 — HIGH — Webhook delivery is an SSRF with response exfiltration and no timeout.**
+`deliverWebhookJob` (`src/webhooks/dispatch.ts:111`) does `fetch(hook.url)` where `hook.url` is stored unvalidated by `createWebhook`/`updateWebhook` — no scheme check, no block for `127.0.0.1` / `169.254.169.254` / RFC-1918 / `.internal`. A `manageSettings` holder can point a webhook at cloud metadata or internal services (fired by creating content that emits a subscribed event), and the **response status + body (first 2000 chars) is persisted** (`dispatch.ts:120-136`) and readable via `GET /api/v1/webhooks/:id/deliveries` — turning blind SSRF into full read. There is also **no `fetch` timeout**, so a slow endpoint ties up a job worker indefinitely.
+**Fix:** validate the URL is `https?://` resolving to a public IP (re-check on redirect) or restrict to an operator allowlist; add `AbortSignal.timeout(...)`.
+
+**3.4 — MEDIUM — Unescaped user data in notification-email HTML (HTML/email injection).**
+`forms.ts:87` builds `submissionHtml` as `