create-shibumi 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shibumi",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Scaffold a Shibumi Stack project: Bun, Hono, Zod, Drizzle, SQLite, Alpine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.0",
2
+ "version": "0.2.7",
3
3
  "extensions": [
4
4
  {
5
5
  "name": "admin",
@@ -18,5 +18,5 @@
18
18
  "version": "1.0.1"
19
19
  }
20
20
  ],
21
- "sha256": "10386d9e6db61626780e85ef6bcc423e88402606b8ce9baa2c222e61cd32f134"
21
+ "sha256": "f859ca8e76ee58d8bbafb162ec8e06a794c9db6586759916a8e4b4d45568c5d4"
22
22
  }
@@ -2,6 +2,12 @@
2
2
  Edit freely; this file is yours. Tokens: --paper --ink --muted --line
3
3
  --accent, type scale --text-xs..--text-2xl, --font-sans/serif/mono. */
4
4
 
5
+ /* The vendored shibumi.css sets a kozo paper background for the sites; apps
6
+ stay flat, and this override also stops the browser fetching the image. */
7
+ body {
8
+ background-image: none;
9
+ }
10
+
5
11
  .scaffold {
6
12
  max-width: 40.625rem;
7
13
  margin: 0 auto;
@@ -60,19 +60,23 @@ body {
60
60
  background: var(--bg);
61
61
  }
62
62
 
63
- h1, h2, h3 {
64
- font-family: var(--font-serif);
63
+ /* Kozo paper texture, light theme only; dark stays flat ink. The image lives
64
+ next to this stylesheet in every consumer, hence the relative URL. */
65
+ body {
66
+ background-image: url("kozo.webp");
67
+ background-size: cover;
68
+ background-position: center;
69
+ background-attachment: fixed;
70
+ background-repeat: no-repeat;
65
71
  }
72
+ @media (prefers-color-scheme: dark) {
73
+ body { background-image: none; }
74
+ }
75
+ [data-theme="light"] body { background-image: url("kozo.webp"); }
76
+ [data-theme="dark"] body { background-image: none; }
66
77
 
67
- /* theme-independent noise texture, copied from shibumi-server */
68
- body::before {
69
- content: "";
70
- position: fixed;
71
- inset: 0;
72
- z-index: -1;
73
- pointer-events: none;
74
- opacity: 0.33;
75
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.88' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E");
78
+ h1, h2, h3 {
79
+ font-family: var(--font-serif);
76
80
  }
77
81
 
78
82
  a { color: inherit; }
@@ -123,9 +127,9 @@ main a:not(.btn):not(.button):focus-visible {
123
127
  width: 100vw;
124
128
  height: 100%;
125
129
  transform: translateX(-50%);
126
- background: light-dark(rgb(245 240 228 / 78%), rgb(27 19 15 / 72%));
127
- backdrop-filter: blur(1.125rem);
128
- -webkit-backdrop-filter: blur(1.125rem);
130
+ background: light-dark(rgb(245 240 228 / 0%), rgb(27 19 15 / 72%));
131
+ backdrop-filter: blur(1.25rem);
132
+ -webkit-backdrop-filter: blur(1.25rem);
129
133
  }
130
134
 
131
135
  .mark {
@@ -54,7 +54,7 @@ export interface ExtensionBundle {
54
54
  }
55
55
 
56
56
  // --- shibumi:extensions:start (generated by scripts/sync-extensions.ts; do not edit) ---
57
- const EXTENSIONS_JSON = "[{\"name\":\"admin\",\"title\":\"Admin\",\"description\":\"Minimal server-rendered admin panel: list and delete users, gated by an ADMIN_EMAILS allowlist\",\"version\":\"1.0.1\",\"requires\":\"database\",\"dependsOn\":[\"auth\"],\"env\":[\"ADMIN_EMAILS\"],\"files\":[{\"to\":\"public/admin.css\",\"content\":\"/* Admin panel styles. Reuses the paper/ink/persimmon tokens from style.css;\\n utilitarian, no framework. */\\n.admin {\\n max-width: 60rem;\\n}\\n\\n.admin .muted {\\n opacity: 0.65;\\n font-size: 0.9rem;\\n}\\n\\n.admin table {\\n width: 100%;\\n border-collapse: collapse;\\n margin-top: 1rem;\\n}\\n\\n.admin th,\\n.admin td {\\n text-align: left;\\n padding: 0.5rem 0.75rem;\\n border-bottom: 1px solid color-mix(in srgb, var(--ink) 15%, transparent);\\n vertical-align: middle;\\n}\\n\\n.admin th {\\n font-size: 0.8rem;\\n text-transform: uppercase;\\n letter-spacing: 0.04em;\\n opacity: 0.7;\\n}\\n\\n.admin form {\\n margin: 0;\\n}\\n\\n.admin button {\\n font: inherit;\\n cursor: pointer;\\n border: 1px solid color-mix(in srgb, var(--ink) 25%, transparent);\\n background: transparent;\\n color: var(--ink);\\n padding: 0.3rem 0.7rem;\\n border-radius: 0.3rem;\\n}\\n\\n.admin button.danger {\\n border-color: var(--accent);\\n color: var(--accent);\\n}\\n\\n.admin button.danger:hover {\\n background: var(--accent);\\n color: var(--paper);\\n}\\n\"},{\"to\":\"public/admin.js\",\"content\":\"// Admin panel behavior. Self-hosted so it runs under the app's\\n// script-src 'self' CSP. Confirms destructive form submits using the\\n// message in each form's data-confirm attribute (safe against apostrophes\\n// and markup, unlike an inline handler).\\ndocument.addEventListener(\\\"submit\\\", (event) => {\\n const form = event.target;\\n if (!(form instanceof HTMLFormElement)) return;\\n const message = form.dataset.confirm;\\n if (message && !window.confirm(message)) {\\n event.preventDefault();\\n }\\n});\\n\"},{\"to\":\"src/lib/admin.ts\",\"content\":\"// Admin authorization and read models. Installed by\\n// `bun run shibumi add admin` (needs the auth extension). This project owns\\n// the file. Admins are defined by the ADMIN_EMAILS allowlist, not a database\\n// flag, so there is no schema coupling to the auth tables and no bootstrap\\n// step: set the env var and that account is an admin.\\nimport { eq } from \\\"drizzle-orm\\\";\\nimport { db, sqlite } from \\\"../db\\\";\\nimport { users } from \\\"../db/schema-auth\\\";\\nimport { loadEnv } from \\\"../env\\\";\\nimport { normalizeEmail } from \\\"./auth\\\";\\n\\nexport function adminEmails(): Set<string> {\\n const raw = loadEnv().ADMIN_EMAILS ?? \\\"\\\";\\n return new Set(\\n raw\\n .split(\\\",\\\")\\n .map((entry) => normalizeEmail(entry))\\n .filter(Boolean)\\n );\\n}\\n\\nexport function isAdmin(email: string): boolean {\\n const allow = adminEmails();\\n return allow.size > 0 && allow.has(normalizeEmail(email));\\n}\\n\\n// Does a given table exist? Lets the admin read upload counts only when the\\n// uploads extension is installed, without importing its schema.\\nfunction tableExists(name: string): boolean {\\n const row = sqlite\\n .query<{ n: number }, [string]>(\\\"SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name=?\\\")\\n .get(name);\\n return (row?.n ?? 0) > 0;\\n}\\n\\nexport interface AdminUserRow {\\n id: number;\\n email: string;\\n createdAt: string;\\n sessions: number;\\n uploads: number | null;\\n}\\n\\nexport function listUsers(): AdminUserRow[] {\\n const rows = db.select().from(users).all();\\n const hasUploads = tableExists(\\\"uploads\\\");\\n const sessionCount = sqlite.query<{ n: number }, [number]>(\\n \\\"SELECT count(*) AS n FROM sessions WHERE user_id = ?\\\"\\n );\\n const uploadCount = hasUploads\\n ? sqlite.query<{ n: number }, [number]>(\\\"SELECT count(*) AS n FROM uploads WHERE user_id = ?\\\")\\n : null;\\n return rows.map((row) => ({\\n id: row.id,\\n email: row.email,\\n createdAt: row.createdAt,\\n sessions: sessionCount.get(row.id)?.n ?? 0,\\n uploads: uploadCount ? (uploadCount.get(row.id)?.n ?? 0) : null,\\n }));\\n}\\n\\n// Deleting a user cascades to sessions, login tokens, and uploads rows via the\\n// foreign keys those tables declare. Stored upload blobs are not swept here;\\n// the uploads extension owns that.\\nexport async function deleteUser(id: number): Promise<boolean> {\\n const removed = await db.delete(users).where(eq(users.id, id)).returning();\\n return removed.length > 0;\\n}\\n\"},{\"to\":\"src/routes/admin.ts\",\"content\":\"// Admin panel, mounted at /admin by the installer. Server-rendered HTML, no\\n// client JS: actions are plain form POSTs. Every route requires a session and\\n// an email on the ADMIN_EMAILS allowlist; CSRF covers the mutations.\\nimport { Hono } from \\\"hono\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { getCookie } from \\\"hono/cookie\\\";\\nimport { SESSION_COOKIE, csrfOptions, sessionUser, type AuthUser } from \\\"../lib/auth\\\";\\nimport { deleteUser, isAdmin, listUsers, type AdminUserRow } from \\\"../lib/admin\\\";\\n\\ntype AdminEnv = { Variables: { user: AuthUser } };\\n\\nexport const adminRoutes = new Hono<AdminEnv>();\\n\\nadminRoutes.use(csrf(csrfOptions()));\\n// Session + allowlist gate. Not `requireAuth`: admins get a 403 page, signed-out\\n// visitors a 401, both as HTML rather than JSON.\\nadminRoutes.use(async (c, next) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n if (!user) return c.html(page(\\\"Sign in required\\\", \\\"<p>Sign in to reach the admin panel.</p>\\\"), 401);\\n if (!isAdmin(user.email)) return c.html(page(\\\"Forbidden\\\", \\\"<p>This account is not an administrator.</p>\\\"), 403);\\n c.set(\\\"user\\\", user);\\n await next();\\n});\\n\\nfunction escapeHtml(value: string): string {\\n return value\\n .replaceAll(\\\"&\\\", \\\"&amp;\\\")\\n .replaceAll(\\\"<\\\", \\\"&lt;\\\")\\n .replaceAll(\\\">\\\", \\\"&gt;\\\")\\n .replaceAll('\\\"', \\\"&quot;\\\")\\n .replaceAll(\\\"'\\\", \\\"&#39;\\\");\\n}\\n\\nfunction page(title: string, body: string): string {\\n return `<!doctype html>\\n<html lang=\\\"en\\\">\\n <head>\\n <meta charset=\\\"utf-8\\\" />\\n <meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\" />\\n <title>${escapeHtml(title)}</title>\\n <link rel=\\\"stylesheet\\\" href=\\\"/public/style.css\\\" />\\n <link rel=\\\"stylesheet\\\" href=\\\"/public/admin.css\\\" />\\n <!-- Self-hosted so it runs under the app's script-src 'self' CSP; an\\n inline handler would be blocked. -->\\n <script src=\\\"/public/admin.js\\\" defer></script>\\n </head>\\n <body>\\n <main class=\\\"admin\\\">\\n <h1>${escapeHtml(title)}</h1>\\n ${body}\\n </main>\\n </body>\\n</html>\\n`;\\n}\\n\\nfunction usersTable(rows: AdminUserRow[], self: number): string {\\n if (rows.length === 0) return \\\"<p>No users yet.</p>\\\";\\n const showUploads = rows.some((row) => row.uploads !== null);\\n const head = `<tr><th>ID</th><th>Email</th><th>Created</th><th>Sessions</th>${\\n showUploads ? \\\"<th>Uploads</th>\\\" : \\\"\\\"\\n }<th></th></tr>`;\\n const body = rows\\n .map((row) => {\\n const uploads = showUploads ? `<td>${row.uploads ?? 0}</td>` : \\\"\\\";\\n const action =\\n row.id === self\\n ? `<td class=\\\"muted\\\">you</td>`\\n : `<td><form method=\\\"post\\\" action=\\\"/admin/users/${row.id}/delete\\\" data-confirm=\\\"Delete ${escapeHtml(\\n row.email\\n )}?\\\"><button type=\\\"submit\\\" class=\\\"danger\\\">Delete</button></form></td>`;\\n return `<tr><td>${row.id}</td><td>${escapeHtml(row.email)}</td><td>${escapeHtml(\\n row.createdAt\\n )}</td><td>${row.sessions}</td>${uploads}${action}</tr>`;\\n })\\n .join(\\\"\\\");\\n return `<table>${head}${body}</table>`;\\n}\\n\\nadminRoutes.get(\\\"/\\\", (c) => {\\n const rows = listUsers();\\n return c.html(\\n page(\\n \\\"Users\\\",\\n `<p class=\\\"muted\\\">${rows.length} account${rows.length === 1 ? \\\"\\\" : \\\"s\\\"}.</p>${usersTable(\\n rows,\\n c.get(\\\"user\\\").id\\n )}`\\n )\\n );\\n});\\n\\nadminRoutes.post(\\\"/users/:id/delete\\\", async (c) => {\\n const raw = c.req.param(\\\"id\\\") ?? \\\"\\\";\\n const id = /^\\\\d+$/.test(raw) ? Number(raw) : NaN;\\n if (!Number.isSafeInteger(id)) return c.html(page(\\\"Bad request\\\", \\\"<p>Invalid id.</p>\\\"), 400);\\n if (id === c.get(\\\"user\\\").id) {\\n return c.html(page(\\\"Not allowed\\\", \\\"<p>You cannot delete your own account here.</p>\\\"), 400);\\n }\\n await deleteUser(id);\\n return c.redirect(\\\"/admin\\\");\\n});\\n\"},{\"to\":\"test/admin.test.ts\",\"content\":\"import { beforeAll, describe, expect, it } from \\\"bun:test\\\";\\nimport { mkdtempSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\nprocess.env.DB_PATH = join(mkdtempSync(join(tmpdir(), \\\"admin-test-\\\")), \\\"app.db\\\");\\nprocess.env.ADMIN_EMAILS = \\\"boss@example.com, Owner@Example.com\\\";\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { createUser, createSession, SESSION_COOKIE } = await import(\\\"../src/lib/auth\\\");\\nconst { isAdmin, listUsers } = await import(\\\"../src/lib/admin\\\");\\nawait applyMigrations(sqlite);\\n\\nlet counter = 0;\\nasync function makeUser(email?: string): Promise<{ id: number; email: string; cookie: string }> {\\n counter += 1;\\n const addr = email ?? `member${counter}-${Date.now()}@example.com`;\\n const user = await createUser(addr, \\\"password123\\\");\\n const token = await createSession(user.id);\\n return { id: user.id, email: addr, cookie: `${SESSION_COOKIE}=${token}` };\\n}\\n\\nlet admin: { id: number; cookie: string };\\nbeforeAll(async () => {\\n admin = await makeUser(\\\"boss@example.com\\\");\\n});\\n\\ndescribe(\\\"isAdmin\\\", () => {\\n it(\\\"matches the allowlist case-insensitively and rejects others\\\", () => {\\n expect(isAdmin(\\\"boss@example.com\\\")).toBe(true);\\n expect(isAdmin(\\\"owner@example.com\\\")).toBe(true);\\n expect(isAdmin(\\\"OWNER@EXAMPLE.COM\\\")).toBe(true);\\n expect(isAdmin(\\\"nobody@example.com\\\")).toBe(false);\\n expect(isAdmin(\\\"\\\")).toBe(false);\\n });\\n});\\n\\ndescribe(\\\"access control\\\", () => {\\n it(\\\"401s when signed out\\\", async () => {\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\"));\\n expect(res.status).toBe(401);\\n });\\n\\n it(\\\"403s a signed-in non-admin\\\", async () => {\\n const member = await makeUser();\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\", { headers: { cookie: member.cookie } }));\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"renders the user table for an admin\\\", async () => {\\n const member = await makeUser(\\\"visible@example.com\\\");\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\", { headers: { cookie: admin.cookie } }));\\n expect(res.status).toBe(200);\\n expect(res.headers.get(\\\"content-type\\\")).toContain(\\\"text/html\\\");\\n const html = await res.text();\\n expect(html).toContain(\\\"visible@example.com\\\");\\n expect(html).toContain(\\\"/admin/users/\\\");\\n void member;\\n });\\n});\\n\\ndescribe(\\\"delete user\\\", () => {\\n it(\\\"blocks cross-origin form posts (CSRF)\\\", async () => {\\n const target = await makeUser();\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"https://evil.example\\\", \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\" },\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"lets an admin delete another user and refuses self-delete\\\", async () => {\\n const target = await makeUser(\\\"doomed@example.com\\\");\\n const before = listUsers().length;\\n\\n const selfDelete = await app.fetch(\\n new Request(`http://localhost/admin/users/${admin.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(selfDelete.status).toBe(400);\\n\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(res.status).toBe(302);\\n expect(listUsers().length).toBe(before - 1);\\n expect(listUsers().some((row) => row.email === \\\"doomed@example.com\\\")).toBe(false);\\n });\\n\\n it(\\\"a non-admin cannot delete\\\", async () => {\\n const attacker = await makeUser();\\n const target = await makeUser();\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: attacker.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(res.status).toBe(403);\\n expect(listUsers().some((row) => row.id === target.id)).toBe(true);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { adminRoutes } from \\\"./routes/admin\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/admin\\\", adminRoutes);\"},{\"file\":\"src/env.ts\",\"find\":\" DB_PATH: z.string().min(1).default(\\\"data/app.db\\\"),\",\"insert\":\" // Admin extension (shibumi add admin): comma-separated admin emails.\\n ADMIN_EMAILS: z.string().optional(),\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Admin extension (bun run shibumi add admin): allowlist-gated and\\n // CSRF-protected; covered by test/admin.test.ts. Two ALL entries: the\\n // CSRF and admin-guard middlewares.\\n \\\"ALL /admin/*\\\",\\n \\\"ALL /admin/*\\\",\\n \\\"GET /admin\\\",\\n \\\"POST /admin/users/:id/delete\\\",\"}],\"migration\":null,\"agentsFile\":\"# Admin extension\\n\\nInstalled by `bun run shibumi add admin`. Needs the auth extension. This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/admin.ts`: the `ADMIN_EMAILS` allowlist check (`isAdmin`), the user read model (`listUsers`), and `deleteUser`.\\n- `src/routes/admin.ts`: server-rendered panel mounted at `/admin` (no client JS; actions are form POSTs).\\n- `public/admin.css`: small utilitarian styles reusing the template's paper/ink/persimmon tokens.\\n- `test/admin.test.ts`: access-control, CSRF, and delete coverage.\\n\\n## Who is an admin\\n\\nAdmins are the emails in the `ADMIN_EMAILS` environment variable (comma-separated, case-insensitive), validated in `src/env.ts`. There is no database flag: set the variable and that account is an admin. With `ADMIN_EMAILS` empty, no one is an admin and `/admin` is locked.\\n\\nBecause access is granted by email, the auth extension **reserves** every `ADMIN_EMAILS` address from self-service password registration (a `403` at `/auth/register`), so an attacker cannot register the admin address before you. Create the admin account by one of:\\n\\n- **Login link** (proves inbox control): request `/auth/login-link` for the admin email once the email extension is wired.\\n- **Console seed, local development**: `bun -e 'import { createUser } from \\\"./src/lib/auth\\\"; await createUser(\\\"you@example.com\\\", process.env.SEED_PW!)'` with `SEED_PW` set in the environment. This writes the local database only.\\n- **Console seed, deployed server**: the shipped container carries compiled `dist/` without `src/`, so the import above cannot run there. Seed the production database inside the container instead (same `Bun.password` default hash):\\n\\n ```\\n printf '%s' \\\"$SEED_PW\\\" | podman exec -i <container-name> bun -e '\\n const pw = await new Response(Bun.stdin.stream()).text();\\n const { Database } = await import(\\\"bun:sqlite\\\");\\n new Database(\\\"/data/app.db\\\").run(\\n \\\"INSERT INTO users (email, password_hash) VALUES (?, ?)\\\",\\n [\\\"you@example.com\\\", await Bun.password.hash(pw)]);'\\n ```\\n\\n The password arrives over stdin so it never appears in the host process list.\\n\\nSet `ADMIN_EMAILS` before the app is publicly reachable: `bun ship:env set ADMIN_EMAILS=you@example.com`, then `bun ship`.\\n\\n## Endpoints\\n\\n- `GET /admin` → HTML user table (id, email, created, session count, upload count when the uploads extension is installed).\\n- `POST /admin/users/:id/delete` → deletes a user (cascades to sessions, login tokens, and upload rows via their foreign keys); refuses self-deletion. CSRF protected.\\n\\nSigned-out visitors get a 401 page, signed-in non-admins a 403 page.\\n\\n## Notes\\n\\n- Deleting a user does not sweep stored upload blobs on disk; the uploads extension owns that lifecycle. Clear `<db-dir>/uploads` separately if needed.\\n- The panel is intentionally minimal. Add columns or actions in `src/routes/admin.ts`; keep every mutation a CSRF-protected POST and every route behind the allowlist gate.\\n\\n## Removal\\n\\n`bun run shibumi remove admin` deletes the code and reverses the edits. Remove `admin` before removing `auth`. Remove the `ADMIN_EMAILS` variable from the environment when no longer needed.\\n\",\"rootSection\":\"## Admin extension\\n\\nInstalled by `bun run shibumi add admin` (needs the auth extension); full guide in `agents/admin.md`.\\n\\n- Server-rendered panel at `/admin` (`src/routes/admin.ts`); authorization and read models in `src/lib/admin.ts`; styles in `public/admin.css`.\\n- Admins are the emails in `ADMIN_EMAILS` (comma-separated, case-insensitive), validated in `src/env.ts`. Empty means no admins and `/admin` is locked.\\n- `GET /admin` lists users; `POST /admin/users/:id/delete` deletes one (CSRF-protected, cascades via foreign keys, refuses self-delete). Signed-out visitors get 401, non-admins 403.\\n- No new tables. Deleting a user does not sweep upload blobs; the uploads extension owns that.\\n- Removal deletes code and reverses edits; remove admin before auth.\",\"removeNote\":\"Remove admin before auth (admin depends on it). Remove the ADMIN_EMAILS variable from the environment when no longer needed.\"},{\"name\":\"auth\",\"title\":\"Auth\",\"description\":\"Cookie sessions with password and login-link sign-in, CSRF protection, and rate limiting\",\"version\":\"1.0.1\",\"requires\":\"database\",\"env\":[\"APP_ORIGIN\"],\"files\":[{\"to\":\"src/config/auth.yaml\",\"content\":\"# Auth extension config. Bundled into the image at build time; edit and\\n# re-deploy (bun ship) to apply. Validated at startup by src/lib/auth.ts, which\\n# refuses to boot on a bad value.\\n\\n# How long a session cookie stays valid, in days.\\nsession_days: 7\\n\\n# How long a login link stays valid, in minutes.\\nlogin_link_minutes: 15\\n\\n# Minimum password length (the maximum is fixed at 128).\\npassword_min_length: 8\\n\\n# Per-IP rate limits, each counted per 15 minutes.\\nregister_rate_per_15min: 10\\nlogin_rate_per_15min: 10\\nlogin_link_rate_per_15min: 5\\n\"},{\"to\":\"src/db/schema-auth.ts\",\"content\":\"import { sql } from \\\"drizzle-orm\\\";\\nimport { integer, sqliteTable, text } from \\\"drizzle-orm/sqlite-core\\\";\\n\\n// Owned by the auth extension (bun run shibumi add auth). These tables live\\n// in the same app.db as the rest of the project, created by the installed\\n// migration in src/db/migrations/; keep schema and migration in sync.\\nexport const users = sqliteTable(\\\"users\\\", {\\n id: integer(\\\"id\\\").primaryKey({ autoIncrement: true }),\\n email: text(\\\"email\\\").notNull().unique(),\\n // Null for accounts that only ever log in via login link.\\n passwordHash: text(\\\"password_hash\\\"),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\\n// Sessions and login tokens store sha256 hashes of the tokens handed to the\\n// browser, never the tokens themselves; a leaked database cannot mint logins.\\nexport const sessions = sqliteTable(\\\"sessions\\\", {\\n tokenHash: text(\\\"token_hash\\\").primaryKey(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n expiresAt: text(\\\"expires_at\\\").notNull(),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\\nexport const loginTokens = sqliteTable(\\\"login_tokens\\\", {\\n tokenHash: text(\\\"token_hash\\\").primaryKey(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n expiresAt: text(\\\"expires_at\\\").notNull(),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\"},{\"to\":\"src/lib/auth.ts\",\"content\":\"// Auth core: users, hashed cookie sessions, single-use login tokens, and a\\n// fixed-window rate limiter. Installed by `bun run shibumi add auth`; this\\n// project owns the file. Full guide: agents/auth.md.\\n//\\n// Invariants:\\n// - Session and login tokens leave this module only as opaque random strings;\\n// the database stores sha256 hashes, so a leaked database cannot mint\\n// sessions or logins.\\n// - Login tokens are single-use (deleted on first consume, valid or not) and\\n// expire after 15 minutes.\\n// - Password checks always run Bun.password.verify, including for unknown\\n// emails, so response timing does not reveal whether an account exists.\\n// - The rate limiter is in-memory and per-process, which matches the\\n// single-container deployment; counts reset on restart.\\nimport type { Context, Next } from \\\"hono\\\";\\nimport { getCookie } from \\\"hono/cookie\\\";\\nimport { eq, lt } from \\\"drizzle-orm\\\";\\nimport { db } from \\\"../db\\\";\\nimport { loginTokens, sessions, users } from \\\"../db/schema-auth\\\";\\n// Editable knobs live in config/auth.yaml; Bun bundles the parsed values into\\n// the image at build time. Edit that file and re-deploy to change them.\\nimport rawAuthConfig from \\\"../config/auth.yaml\\\";\\n\\n// A bad edit throws here at module load, so the container fails its health\\n// check and the previous deployment stays live instead of running with a\\n// weakened limit.\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`auth config: ${key} must be a positive integer (config/auth.yaml)`);\\n }\\n return value;\\n}\\n\\nconst authConfig = (rawAuthConfig ?? {}) as Record<string, unknown>;\\n\\nconst SESSION_TTL_MS = positiveInt(authConfig, \\\"session_days\\\") * 24 * 60 * 60 * 1000;\\nconst LOGIN_TOKEN_TTL_MS = positiveInt(authConfig, \\\"login_link_minutes\\\") * 60 * 1000;\\n\\n// Rate limits (per IP, per 15-minute window) and the password floor are read\\n// by the routes; secondary per-email buckets and internal caps stay fixed.\\nexport const RATE_WINDOW_MS = 15 * 60 * 1000;\\nexport const PASSWORD_MIN_LENGTH = positiveInt(authConfig, \\\"password_min_length\\\");\\nexport const REGISTER_RATE = positiveInt(authConfig, \\\"register_rate_per_15min\\\");\\nexport const LOGIN_RATE = positiveInt(authConfig, \\\"login_rate_per_15min\\\");\\nexport const LOGIN_LINK_RATE = positiveInt(authConfig, \\\"login_link_rate_per_15min\\\");\\n\\nexport const SESSION_COOKIE = \\\"session\\\";\\nexport const SESSION_MAX_AGE_S = SESSION_TTL_MS / 1000;\\n\\nexport interface AuthUser {\\n id: number;\\n email: string;\\n createdAt: string;\\n}\\n\\ntype UserRow = typeof users.$inferSelect;\\n\\nfunction toAuthUser(row: UserRow): AuthUser {\\n return { id: row.id, email: row.email, createdAt: row.createdAt };\\n}\\n\\nexport function normalizeEmail(email: string): string {\\n return email.trim().toLowerCase();\\n}\\n\\nfunction newToken(): string {\\n const bytes = new Uint8Array(32);\\n crypto.getRandomValues(bytes);\\n return Buffer.from(bytes).toString(\\\"base64url\\\");\\n}\\n\\nexport function hashToken(token: string): string {\\n return new Bun.CryptoHasher(\\\"sha256\\\").update(token).digest(\\\"hex\\\");\\n}\\n\\n// Emails listed in ADMIN_EMAILS are privileged (the admin extension grants\\n// them access by address). They must not be claimable through self-service\\n// password registration, or an attacker could register the admin address\\n// before the operator. Reserved addresses can still sign in via the login\\n// link, which proves control of the inbox. No-op when ADMIN_EMAILS is unset\\n// (e.g. the admin extension is not installed).\\nexport function isReservedEmail(email: string): boolean {\\n const raw = process.env[\\\"ADMIN_EMAILS\\\"];\\n if (!raw) return false;\\n const target = normalizeEmail(email);\\n return raw\\n .split(\\\",\\\")\\n .map((entry) => normalizeEmail(entry))\\n .filter(Boolean)\\n .includes(target);\\n}\\n\\nfunction nowIso(): string {\\n return new Date().toISOString();\\n}\\n\\n// Users ----------------------------------------------------------------------\\n\\n// Throws on duplicate email (UNIQUE constraint); routes map that to 409.\\nexport async function createUser(email: string, password: string | null): Promise<AuthUser> {\\n const passwordHash = password === null ? null : await Bun.password.hash(password);\\n const rows = await db\\n .insert(users)\\n .values({ email: normalizeEmail(email), passwordHash })\\n .returning();\\n return toAuthUser(rows[0]!);\\n}\\n\\nexport async function getUserByEmail(email: string): Promise<UserRow | null> {\\n const rows = await db.select().from(users).where(eq(users.email, normalizeEmail(email)));\\n return rows[0] ?? null;\\n}\\n\\nexport async function getUserById(id: number): Promise<AuthUser | null> {\\n const rows = await db.select().from(users).where(eq(users.id, id));\\n return rows[0] ? toAuthUser(rows[0]) : null;\\n}\\n\\n// Verifying against this hash when the account is missing (or has no\\n// password) keeps timing uniform without ever granting access: the guard\\n// below requires the account's own stored hash to have matched.\\nlet dummyHashPromise: Promise<string> | undefined;\\nfunction dummyHash(): Promise<string> {\\n dummyHashPromise ??= Bun.password.hash(crypto.randomUUID());\\n return dummyHashPromise;\\n}\\n// Warm it at import so the first unknown-email login is not measurably\\n// slower than a known-email one.\\nvoid dummyHash();\\n\\nexport async function verifyLogin(email: string, password: string): Promise<AuthUser | null> {\\n const row = await getUserByEmail(email);\\n const ok = await Bun.password.verify(password, row?.passwordHash ?? (await dummyHash()));\\n if (!ok || !row?.passwordHash) return null;\\n return toAuthUser(row);\\n}\\n\\n// Sessions -------------------------------------------------------------------\\n\\nexport async function createSession(userId: number): Promise<string> {\\n // Login is the write path, so piggyback expired-row cleanup here and keep\\n // per-request session reads to a single lookup.\\n await db.delete(sessions).where(lt(sessions.expiresAt, nowIso()));\\n const token = newToken();\\n await db.insert(sessions).values({\\n tokenHash: hashToken(token),\\n userId,\\n expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),\\n });\\n return token;\\n}\\n\\nexport async function sessionUser(token: string): Promise<AuthUser | null> {\\n const tokenHash = hashToken(token);\\n const rows = await db\\n .select({ user: users, expiresAt: sessions.expiresAt })\\n .from(sessions)\\n .innerJoin(users, eq(sessions.userId, users.id))\\n .where(eq(sessions.tokenHash, tokenHash));\\n const row = rows[0];\\n if (!row) return null;\\n if (row.expiresAt <= nowIso()) {\\n await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));\\n return null;\\n }\\n return toAuthUser(row.user);\\n}\\n\\nexport async function destroySession(token: string): Promise<void> {\\n await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));\\n}\\n\\n// Login tokens (login-link flow) ----------------------------------------------\\n\\nexport async function createLoginToken(email: string): Promise<string | null> {\\n const row = await getUserByEmail(email);\\n if (!row) return null;\\n await db.delete(loginTokens).where(lt(loginTokens.expiresAt, nowIso()));\\n const token = newToken();\\n await db.insert(loginTokens).values({\\n tokenHash: hashToken(token),\\n userId: row.id,\\n expiresAt: new Date(Date.now() + LOGIN_TOKEN_TTL_MS).toISOString(),\\n });\\n return token;\\n}\\n\\nexport async function consumeLoginToken(token: string): Promise<AuthUser | null> {\\n // Delete first: the token is spent by the attempt, even an expired one.\\n const rows = await db\\n .delete(loginTokens)\\n .where(eq(loginTokens.tokenHash, hashToken(token)))\\n .returning();\\n const row = rows[0];\\n if (!row || row.expiresAt <= nowIso()) return null;\\n return getUserById(row.userId);\\n}\\n\\n// Delivery seam: the auth extension does not send email itself. With the\\n// email extension installed (bun run shibumi add email), replace the body\\n// with a sendEmail call; agents/auth.md has the exact snippet. Until then,\\n// development logs the link and production refuses instead of silently\\n// swallowing logins.\\n// Bracket access, not process.env.NODE_ENV: `bun build` statically inlines\\n// the dot form at build time (defaulting to \\\"development\\\"), which would defeat\\n// the runtime production check baked into the container. Bracket access is\\n// read at runtime.\\nexport function nodeEnv(): string | undefined {\\n return process.env[\\\"NODE_ENV\\\"];\\n}\\n\\n// Behind a TLS-terminating proxy (Caddy) the app sees its own URL as http://,\\n// so hono's default CSRF check (Origin header vs request URL origin) would\\n// reject every browser form POST in production: the browser sends the https\\n// origin, the request URL yields the http one. Pin the expected origin to\\n// APP_ORIGIN when set; without it (development) the same-origin default stays.\\nexport function csrfOptions(): { origin?: string } {\\n const appOrigin = process.env[\\\"APP_ORIGIN\\\"];\\n if (!appOrigin) {\\n // Unset in production would fall back to the same-origin default and 403\\n // every form POST behind the proxy. Fail loud at boot instead.\\n if (nodeEnv() === \\\"production\\\") {\\n throw new Error(\\\"APP_ORIGIN must be set in production (bun ship:env set APP_ORIGIN=https://your-domain, then bun ship).\\\");\\n }\\n return {};\\n }\\n const url = new URL(appOrigin);\\n // A non-http(s) URL serializes to origin \\\"null\\\", which an attacker can\\n // match from a sandboxed iframe (Origin: null). Refuse it.\\n if (url.protocol !== \\\"https:\\\" && url.protocol !== \\\"http:\\\") {\\n throw new Error(\\\"APP_ORIGIN must be an http(s) URL.\\\");\\n }\\n return { origin: url.origin };\\n}\\n\\nexport async function deliverLoginLink(email: string, url: string): Promise<void> {\\n // Fail-closed: only explicit development logs the link; any other value\\n // (including unset) refuses rather than printing tokens to output.\\n if (nodeEnv() !== \\\"development\\\") {\\n throw new Error(\\n \\\"Login-link delivery is not wired. Install the email extension (bun run shibumi add email) and connect it in src/lib/auth.ts (see agents/auth.md).\\\"\\n );\\n }\\n console.log(`Login link for ${email}: ${url}`);\\n}\\n\\n// Rate limiting ---------------------------------------------------------------\\n\\ninterface RateWindow {\\n start: number;\\n count: number;\\n}\\n\\nconst rateWindows = new Map<string, RateWindow>();\\n// Hard bound on tracked windows so attacker-minted keys (fresh IPs or\\n// emails) cannot grow memory without limit. At the cap, expired windows are\\n// pruned and, if every window is still live, the oldest are evicted; an\\n// attacker filling the map can reset other buckets, but the memory bound\\n// wins over that marginal rate-limit weakening.\\nconst RATE_MAP_CAP = 10_000;\\n\\n// Returns true while the caller stays within `limit` hits per `windowMs`.\\nexport function rateLimit(key: string, limit: number, windowMs: number, now = Date.now()): boolean {\\n const window = rateWindows.get(key);\\n if (window && now - window.start < windowMs) {\\n window.count += 1;\\n return window.count <= limit;\\n }\\n if (!window && rateWindows.size >= RATE_MAP_CAP) {\\n for (const [staleKey, stale] of rateWindows) {\\n if (now - stale.start >= windowMs) rateWindows.delete(staleKey);\\n }\\n while (rateWindows.size >= RATE_MAP_CAP) {\\n const oldest = rateWindows.keys().next().value;\\n if (oldest === undefined) break;\\n rateWindows.delete(oldest);\\n }\\n }\\n rateWindows.set(key, { start: now, count: 1 });\\n return true;\\n}\\n\\n// Middleware -------------------------------------------------------------------\\n\\nexport type AuthEnv = { Variables: { user: AuthUser } };\\n\\nexport async function requireAuth(c: Context, next: Next): Promise<Response | void> {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n if (!user) return c.json({ error: \\\"Authentication required\\\" }, 401);\\n c.set(\\\"user\\\", user);\\n await next();\\n}\\n\\nexport async function optionalAuth(c: Context, next: Next): Promise<void> {\\n const token = getCookie(c, SESSION_COOKIE);\\n if (token) {\\n const user = await sessionUser(token);\\n if (user) c.set(\\\"user\\\", user);\\n }\\n await next();\\n}\\n\"},{\"to\":\"src/routes/auth.ts\",\"content\":\"// Auth routes, mounted at /auth by the installer. JSON bodies in, JSON out.\\n// CSRF middleware (Origin check) covers every mutation; cross-origin JSON\\n// posts are additionally blocked by the browser preflight. Rate limits key on\\n// the client IP; see clientKey for how x-forwarded-for is trusted.\\nimport { Hono } from \\\"hono\\\";\\nimport type { Context } from \\\"hono\\\";\\nimport { getConnInfo } from \\\"hono/bun\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { deleteCookie, getCookie, setCookie } from \\\"hono/cookie\\\";\\nimport { z } from \\\"zod\\\";\\nimport { loadEnv } from \\\"../env\\\";\\nimport {\\n LOGIN_LINK_RATE,\\n LOGIN_RATE,\\n PASSWORD_MIN_LENGTH,\\n RATE_WINDOW_MS,\\n REGISTER_RATE,\\n SESSION_COOKIE,\\n SESSION_MAX_AGE_S,\\n consumeLoginToken,\\n createLoginToken,\\n createSession,\\n createUser,\\n csrfOptions,\\n deliverLoginLink,\\n destroySession,\\n isReservedEmail,\\n nodeEnv,\\n normalizeEmail,\\n rateLimit,\\n sessionUser,\\n verifyLogin,\\n} from \\\"../lib/auth\\\";\\n\\nexport const authRoutes = new Hono();\\n\\nauthRoutes.use(csrf(csrfOptions()));\\n\\n// `website` is a honeypot: a decoy field no real client sends. Bots that\\n// autofill it get a plausible response with no work done.\\nconst credentials = z.object({\\n email: z.email().max(254),\\n password: z.string().min(PASSWORD_MIN_LENGTH).max(128),\\n website: z.string().optional(),\\n});\\n\\nconst emailOnly = z.object({\\n email: z.email().max(254),\\n website: z.string().optional(),\\n});\\n\\nfunction honeypotTripped(body: unknown): boolean {\\n return (\\n typeof body === \\\"object\\\" &&\\n body !== null &&\\n \\\"website\\\" in body &&\\n typeof (body as { website: unknown }).website === \\\"string\\\" &&\\n (body as { website: string }).website.length > 0\\n );\\n}\\n\\nfunction fakeRegisterResponse(c: Context, email: string): Response {\\n // Shaped like the real 201, decoy session cookie included, so the bot\\n // learns nothing; no account or session exists.\\n const decoy = new Uint8Array(32);\\n crypto.getRandomValues(decoy);\\n setSessionCookie(c, Buffer.from(decoy).toString(\\\"base64url\\\"));\\n return c.json(\\n {\\n user: {\\n id: 1 + Math.floor(Math.random() * 1000),\\n email,\\n createdAt: new Date().toISOString(),\\n },\\n },\\n 201\\n );\\n}\\n\\nfunction isPrivateAddress(addr: string): boolean {\\n const ip = addr.replace(/^::ffff:/, \\\"\\\");\\n return (\\n ip === \\\"127.0.0.1\\\" ||\\n ip === \\\"::1\\\" ||\\n ip.startsWith(\\\"10.\\\") ||\\n ip.startsWith(\\\"192.168.\\\") ||\\n /^172\\\\.(1[6-9]|2\\\\d|3[01])\\\\./.test(ip) ||\\n ip.startsWith(\\\"fd\\\") ||\\n ip.startsWith(\\\"fc\\\")\\n );\\n}\\n\\n// Rate-limit key. x-forwarded-for is trusted only when the direct socket peer\\n// is a local reverse proxy (the shibumi-server / Caddy deployment) or when\\n// there is no socket peer at all (non-served test context). A directly\\n// reachable production app has a public peer and keys on it, so a spoofed\\n// x-forwarded-for cannot rotate rate-limit buckets.\\nfunction clientKey(c: Context): string {\\n let peer = \\\"\\\";\\n try {\\n peer = getConnInfo(c).remote.address ?? \\\"\\\";\\n } catch {\\n peer = \\\"\\\";\\n }\\n // A reverse proxy APPENDS the client it saw to x-forwarded-for, so the last\\n // entry is the one the trusted proxy added; the leftmost is attacker-set and\\n // must never be used as a key. Trust the header only when the direct peer is\\n // a local proxy (or, in tests, there is no socket peer).\\n if (!peer || isPrivateAddress(peer)) {\\n const entries = c.req.header(\\\"x-forwarded-for\\\")?.split(\\\",\\\").map((part) => part.trim()).filter(Boolean);\\n const last = entries?.at(-1);\\n if (last) return last;\\n }\\n return peer || \\\"local\\\";\\n}\\n\\nfunction tooMany(c: Context): Response {\\n return c.json({ error: \\\"Too many attempts. Try again later.\\\" }, 429);\\n}\\n\\n// Auth bodies are tiny (email + password). Reject anything larger before\\n// buffering, so an app that raised the server's global maxRequestBodySize for\\n// another feature (e.g. uploads) does not expose these routes to oversized\\n// JSON. Content-Length covers the common case; the server's global limit\\n// remains the hard ceiling for chunked requests.\\nconst MAX_AUTH_BODY_BYTES = 4 * 1024;\\nasync function jsonBody(c: Context): Promise<unknown> {\\n // Cap the actual bytes read, not just the declared Content-Length, so a\\n // chunked or length-spoofed request cannot buffer up to the server's global\\n // ceiling (raised by e.g. the uploads extension). Auth bodies are tiny.\\n const declared = Number(c.req.header(\\\"content-length\\\") ?? \\\"\\\");\\n if (Number.isFinite(declared) && declared > MAX_AUTH_BODY_BYTES) return null;\\n const body = c.req.raw.body;\\n if (!body) {\\n try {\\n return await c.req.json();\\n } catch {\\n return null;\\n }\\n }\\n const reader = body.getReader();\\n const chunks: Uint8Array[] = [];\\n let total = 0;\\n try {\\n for (;;) {\\n const { done, value } = await reader.read();\\n if (done) break;\\n total += value.length;\\n if (total > MAX_AUTH_BODY_BYTES) {\\n await reader.cancel();\\n return null;\\n }\\n chunks.push(value);\\n }\\n } catch {\\n return null;\\n }\\n try {\\n return JSON.parse(new TextDecoder().decode(Buffer.concat(chunks)));\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction setSessionCookie(c: Context, token: string): void {\\n // A token-bearing response must never be cached by a shared proxy.\\n c.header(\\\"Cache-Control\\\", \\\"no-store\\\");\\n // Secure works in local development too: browsers treat localhost as a\\n // secure context.\\n setCookie(c, SESSION_COOKIE, token, {\\n path: \\\"/\\\",\\n httpOnly: true,\\n secure: true,\\n sameSite: \\\"Lax\\\",\\n maxAge: SESSION_MAX_AGE_S,\\n });\\n}\\n\\nauthRoutes.post(\\\"/register\\\", async (c) => {\\n if (!rateLimit(`auth:register:${clientKey(c)}`, REGISTER_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const body = await jsonBody(c);\\n const parsed = credentials.safeParse(body);\\n if (honeypotTripped(body)) {\\n return fakeRegisterResponse(c, parsed.success ? normalizeEmail(parsed.data.email) : \\\"user@example.com\\\");\\n }\\n if (!parsed.success) {\\n return c.json({ error: `Provide a valid email and a password of ${PASSWORD_MIN_LENGTH} to 128 characters.` }, 400);\\n }\\n if (isReservedEmail(parsed.data.email)) {\\n // Privileged address: cannot be self-registered; must sign in via the\\n // login link (inbox proof) or be seeded by the operator.\\n return c.json({ error: \\\"This address is reserved. Sign in with a login link.\\\" }, 403);\\n }\\n try {\\n const user = await createUser(parsed.data.email, parsed.data.password);\\n setSessionCookie(c, await createSession(user.id));\\n return c.json({ user }, 201);\\n } catch (error) {\\n // Only the duplicate-email case maps to 409; anything else (hashing,\\n // database, session failure) surfaces as the generic 500.\\n if (error instanceof Error && error.message.includes(\\\"UNIQUE constraint failed\\\")) {\\n return c.json({ error: \\\"Email is already registered.\\\" }, 409);\\n }\\n throw error;\\n }\\n});\\n\\nauthRoutes.post(\\\"/login\\\", async (c) => {\\n const body = await jsonBody(c);\\n const parsed = credentials.safeParse(body);\\n // Invalid shapes still consume rate budget keyed by IP alone.\\n const email = parsed.success ? normalizeEmail(parsed.data.email) : \\\"\\\";\\n if (!rateLimit(`auth:login:${clientKey(c)}:${email}`, LOGIN_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n // IP-independent per-account bucket: credential stuffing from many IPs\\n // still hits a ceiling.\\n if (email && !rateLimit(`auth:login:email:${email}`, LOGIN_RATE * 5, RATE_WINDOW_MS)) return tooMany(c);\\n // Honeypot: answer exactly like a failed login, skip the work.\\n const user =\\n parsed.success && !honeypotTripped(body)\\n ? await verifyLogin(parsed.data.email, parsed.data.password)\\n : null;\\n if (!user) return c.json({ error: \\\"Invalid email or password.\\\" }, 401);\\n setSessionCookie(c, await createSession(user.id));\\n return c.json({ user });\\n});\\n\\nauthRoutes.post(\\\"/login-link\\\", async (c) => {\\n if (!rateLimit(`auth:link:${clientKey(c)}`, LOGIN_LINK_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const body = await jsonBody(c);\\n const parsed = emailOnly.safeParse(body);\\n if (!parsed.success) return c.json({ error: \\\"Provide a valid email.\\\" }, 400);\\n // Honeypot: the uniform response below already reveals nothing, so just\\n // skip token creation and delivery.\\n // Per-email bucket, uniform response when exceeded: rotating IPs must not\\n // turn login links into email bombing or a stack of live tokens.\\n const emailAllowed = rateLimit(`auth:link:email:${normalizeEmail(parsed.data.email)}`, LOGIN_LINK_RATE, RATE_WINDOW_MS);\\n const token =\\n honeypotTripped(body) || !emailAllowed ? null : await createLoginToken(parsed.data.email);\\n if (token) {\\n // Links are built from APP_ORIGIN, never the Host header, so a poisoned\\n // Host cannot redirect tokens. Fail-closed: the request-origin fallback\\n // is used only when NODE_ENV is explicitly \\\"development\\\"; any other value\\n // (including unset) requires APP_ORIGIN, and it must be https so tokens\\n // never ride plaintext.\\n const env = loadEnv();\\n const isDevelopment = nodeEnv() === \\\"development\\\";\\n const base = env.APP_ORIGIN ?? (isDevelopment ? new URL(c.req.url).origin : null);\\n try {\\n if (!base) {\\n throw new Error(\\\"APP_ORIGIN is not set; refusing to build login links from the Host header. Set APP_ORIGIN (https://...).\\\");\\n }\\n if (!isDevelopment && !base.startsWith(\\\"https://\\\")) {\\n throw new Error(`APP_ORIGIN must be https in production, got ${base}.`);\\n }\\n const url = new URL(`/auth/verify?token=${token}`, base).toString();\\n await deliverLoginLink(normalizeEmail(parsed.data.email), url);\\n } catch (error) {\\n // Delivery failure must not change the response, or it would reveal\\n // which emails have accounts.\\n console.error(error instanceof Error ? error.message : String(error));\\n }\\n }\\n return c.json({ ok: true, message: \\\"If that email is registered, a login link is on its way.\\\" });\\n});\\n\\nauthRoutes.get(\\\"/verify\\\", async (c) => {\\n if (!rateLimit(`auth:verify:${clientKey(c)}`, LOGIN_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const token = c.req.query(\\\"token\\\") ?? \\\"\\\";\\n const user = token ? await consumeLoginToken(token) : null;\\n if (!user) {\\n return c.json({ error: \\\"This login link is invalid or has expired. Request a new one.\\\" }, 400);\\n }\\n setSessionCookie(c, await createSession(user.id));\\n return c.redirect(\\\"/\\\");\\n});\\n\\nauthRoutes.post(\\\"/logout\\\", async (c) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n if (token) {\\n await destroySession(token);\\n deleteCookie(c, SESSION_COOKIE, { path: \\\"/\\\" });\\n }\\n return c.json({ ok: true });\\n});\\n\\nauthRoutes.get(\\\"/me\\\", async (c) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n return c.json({ user });\\n});\\n\"},{\"to\":\"test/auth.test.ts\",\"content\":\"import { describe, expect, it } from \\\"bun:test\\\";\\nimport { mkdtempSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\n// The db module opens DB_PATH at import time, so point it at a scratch\\n// database before the app loads. When another test file loaded first, its\\n// scratch path already won; migrations below are idempotent either way.\\nprocess.env.DB_PATH = join(mkdtempSync(join(tmpdir(), \\\"auth-test-\\\")), \\\"app.db\\\");\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { db, sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { sessions } = await import(\\\"../src/db/schema-auth\\\");\\nconst {\\n consumeLoginToken,\\n createLoginToken,\\n deliverLoginLink,\\n createSession,\\n createUser,\\n hashToken,\\n rateLimit,\\n sessionUser,\\n} = await import(\\\"../src/lib/auth\\\");\\nconst { eq } = await import(\\\"drizzle-orm\\\");\\nawait applyMigrations(sqlite);\\n\\nlet userCounter = 0;\\nfunction uniqueEmail(): string {\\n userCounter += 1;\\n return `user${userCounter}-${Date.now()}@example.com`;\\n}\\n\\nlet ipCounter = 0;\\nfunction uniqueIp(): string {\\n ipCounter += 1;\\n return `10.1.${Math.floor(ipCounter / 250)}.${(ipCounter % 250) + 1}`;\\n}\\n\\nasync function post(\\n path: string,\\n body: unknown,\\n options: { ip?: string; headers?: Record<string, string> } = {}\\n): Promise<Response> {\\n return app.fetch(\\n new Request(`http://localhost${path}`, {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/json\\\",\\n \\\"x-forwarded-for\\\": options.ip ?? uniqueIp(),\\n ...(options.headers ?? {}),\\n },\\n body: JSON.stringify(body),\\n })\\n );\\n}\\n\\nfunction sessionTokenFrom(res: Response): string {\\n const cookie = res.headers.get(\\\"set-cookie\\\") ?? \\\"\\\";\\n const match = cookie.match(/session=([^;]+)/);\\n expect(match).not.toBeNull();\\n return match![1]!;\\n}\\n\\ndescribe(\\\"register and login\\\", () => {\\n it(\\\"registers, sets a hardened session cookie, and stores only the token hash\\\", async () => {\\n const email = uniqueEmail();\\n const res = await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n expect(res.status).toBe(201);\\n const cookie = res.headers.get(\\\"set-cookie\\\") ?? \\\"\\\";\\n expect(cookie).toContain(\\\"HttpOnly\\\");\\n expect(cookie).toContain(\\\"Secure\\\");\\n expect(cookie).toContain(\\\"SameSite=Lax\\\");\\n expect(cookie).toContain(\\\"Path=/\\\");\\n\\n const token = sessionTokenFrom(res);\\n const rows = await db.select().from(sessions).where(eq(sessions.tokenHash, hashToken(token)));\\n expect(rows.length).toBe(1);\\n expect(rows[0]!.tokenHash).not.toBe(token);\\n });\\n\\n it(\\\"reserves ADMIN_EMAILS addresses from self-service registration\\\", async () => {\\n const reserved = uniqueEmail();\\n const original = process.env.ADMIN_EMAILS;\\n process.env.ADMIN_EMAILS = `${reserved}, someone-else@example.com`;\\n try {\\n const res = await post(\\\"/auth/register\\\", { email: reserved, password: \\\"password123\\\" });\\n expect(res.status).toBe(403);\\n expect(((await res.json()) as { error: string }).error).toContain(\\\"reserved\\\");\\n // A non-reserved address still registers.\\n expect((await post(\\\"/auth/register\\\", { email: uniqueEmail(), password: \\\"password123\\\" })).status).toBe(201);\\n // Reserved address can still get a login link (inbox proof).\\n const link = await post(\\\"/auth/login-link\\\", { email: reserved });\\n expect(link.status).toBe(200);\\n } finally {\\n if (original === undefined) delete process.env.ADMIN_EMAILS;\\n else process.env.ADMIN_EMAILS = original;\\n }\\n });\\n\\n it(\\\"rejects duplicate registration with 409\\\", async () => {\\n const email = uniqueEmail();\\n expect((await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" })).status).toBe(201);\\n expect((await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" })).status).toBe(409);\\n });\\n\\n it(\\\"rejects invalid registration input\\\", async () => {\\n expect((await post(\\\"/auth/register\\\", { email: \\\"not-an-email\\\", password: \\\"password123\\\" })).status).toBe(400);\\n expect((await post(\\\"/auth/register\\\", { email: uniqueEmail(), password: \\\"short\\\" })).status).toBe(400);\\n });\\n\\n it(\\\"logs in with correct credentials and returns one uniform error otherwise\\\", async () => {\\n const email = uniqueEmail();\\n await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n\\n const ok = await post(\\\"/auth/login\\\", { email, password: \\\"password123\\\" });\\n expect(ok.status).toBe(200);\\n\\n const wrongPassword = await post(\\\"/auth/login\\\", { email, password: \\\"wrong-password\\\" });\\n const unknownEmail = await post(\\\"/auth/login\\\", { email: uniqueEmail(), password: \\\"password123\\\" });\\n expect(wrongPassword.status).toBe(401);\\n expect(unknownEmail.status).toBe(401);\\n expect(await wrongPassword.json()).toEqual(await unknownEmail.json());\\n });\\n\\n it(\\\"honeypot submissions get plausible responses but create nothing\\\", async () => {\\n const email = uniqueEmail();\\n const trapped = await post(\\\"/auth/register\\\", {\\n email,\\n password: \\\"password123\\\",\\n website: \\\"https://spam.example\\\",\\n });\\n expect(trapped.status).toBe(201);\\n // A decoy cookie is set so the response is indistinguishable, but it\\n // maps to no session.\\n const decoy = (trapped.headers.get(\\\"set-cookie\\\") ?? \\\"\\\").match(/session=([^;]+)/)![1]!;\\n const decoyMe = await app.fetch(\\n new Request(\\\"http://localhost/auth/me\\\", { headers: { cookie: `session=${decoy}` } })\\n );\\n expect(((await decoyMe.json()) as { user: null }).user).toBeNull();\\n\\n // No account was created, so a real login with those credentials fails.\\n const login = await post(\\\"/auth/login\\\", { email, password: \\\"password123\\\" });\\n expect(login.status).toBe(401);\\n\\n const trappedLogin = await post(\\\"/auth/login\\\", {\\n email,\\n password: \\\"password123\\\",\\n website: \\\"x\\\",\\n });\\n expect(trappedLogin.status).toBe(401);\\n\\n const trappedLink = await post(\\\"/auth/login-link\\\", { email, website: \\\"x\\\" });\\n const realLink = await post(\\\"/auth/login-link\\\", { email: uniqueEmail() });\\n expect(trappedLink.status).toBe(200);\\n expect(await trappedLink.json()).toEqual(await realLink.json());\\n\\n // An empty honeypot field is what real clients send; it must not trip.\\n const clean = await post(\\\"/auth/register\\\", {\\n email: uniqueEmail(),\\n password: \\\"password123\\\",\\n website: \\\"\\\",\\n });\\n expect(clean.status).toBe(201);\\n expect(clean.headers.get(\\\"set-cookie\\\")).not.toBeNull();\\n });\\n\\n it(\\\"rate limits login attempts per IP and email\\\", async () => {\\n const email = uniqueEmail();\\n const ip = uniqueIp();\\n let limited = false;\\n for (let i = 0; i < 11; i++) {\\n const res = await post(\\\"/auth/login\\\", { email, password: \\\"wrong-password\\\" }, { ip });\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n\\n it(\\\"caps an oversized request body (treated as an invalid body)\\\", async () => {\\n // The 4 KiB cap makes jsonBody return null; register reports that as 400.\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/register\\\", {\\n method: \\\"POST\\\",\\n headers: { \\\"content-type\\\": \\\"application/json\\\", \\\"x-forwarded-for\\\": uniqueIp() },\\n body: JSON.stringify({ email: \\\"big@example.com\\\", password: \\\"a\\\".repeat(20 * 1024) }),\\n })\\n );\\n expect(res.status).toBe(400);\\n });\\n\\n it(\\\"keys the rate limit on the proxy-appended (rightmost) forwarded IP\\\", async () => {\\n // The trusted proxy appends the real client; a spoofed leftmost entry must\\n // not create fresh buckets. Fixed rightmost, varying leftmost -> one bucket.\\n const realIp = uniqueIp();\\n let limited = false;\\n for (let i = 0; i < 7; i++) {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/login-link\\\", {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/json\\\",\\n \\\"x-forwarded-for\\\": `203.0.113.${i}, ${realIp}`,\\n },\\n body: JSON.stringify({ email: `r${i}@example.com` }),\\n })\\n );\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"sessions\\\", () => {\\n it(\\\"reports the user on /auth/me and clears the session on logout\\\", async () => {\\n const email = uniqueEmail();\\n const res = await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n const token = sessionTokenFrom(res);\\n const withCookie = { cookie: `session=${token}` };\\n\\n const me = await app.fetch(new Request(\\\"http://localhost/auth/me\\\", { headers: withCookie }));\\n expect(((await me.json()) as { user: { email: string } }).user.email).toBe(email);\\n\\n const logout = await post(\\\"/auth/logout\\\", {}, { headers: withCookie });\\n expect(logout.status).toBe(200);\\n\\n const meAfter = await app.fetch(new Request(\\\"http://localhost/auth/me\\\", { headers: withCookie }));\\n expect(((await meAfter.json()) as { user: null }).user).toBeNull();\\n });\\n\\n it(\\\"rejects expired sessions\\\", async () => {\\n const user = await createUser(uniqueEmail(), \\\"password123\\\");\\n const token = await createSession(user.id);\\n await db\\n .update(sessions)\\n .set({ expiresAt: new Date(Date.now() - 1000).toISOString() })\\n .where(eq(sessions.tokenHash, hashToken(token)));\\n expect(await sessionUser(token)).toBeNull();\\n });\\n\\n it(\\\"blocks cross-origin form posts (CSRF)\\\", async () => {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/logout\\\", {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\",\\n origin: \\\"https://evil.example\\\",\\n \\\"x-forwarded-for\\\": uniqueIp(),\\n },\\n body: \\\"a=1\\\",\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n});\\n\\ndescribe(\\\"login links\\\", () => {\\n it(\\\"answers uniformly whether or not the email exists\\\", async () => {\\n const email = uniqueEmail();\\n await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n const known = await post(\\\"/auth/login-link\\\", { email });\\n const unknown = await post(\\\"/auth/login-link\\\", { email: uniqueEmail() });\\n expect(known.status).toBe(200);\\n expect(unknown.status).toBe(200);\\n expect(await known.json()).toEqual(await unknown.json());\\n });\\n\\n it(\\\"issues high-entropy single-use tokens that log the user in once\\\", async () => {\\n const email = uniqueEmail();\\n const user = await createUser(email, null);\\n const token = await createLoginToken(email);\\n expect(token).not.toBeNull();\\n // 32 random bytes, base64url: 43 characters, unique per issue.\\n expect(token!.length).toBeGreaterThanOrEqual(43);\\n expect(await createLoginToken(email)).not.toBe(token);\\n\\n const consumed = await consumeLoginToken(token!);\\n expect(consumed?.id).toBe(user.id);\\n expect(await consumeLoginToken(token!)).toBeNull();\\n });\\n\\n it(\\\"verify endpoint consumes the token and starts a session\\\", async () => {\\n const email = uniqueEmail();\\n await createUser(email, null);\\n const token = await createLoginToken(email);\\n const res = await app.fetch(\\n new Request(`http://localhost/auth/verify?token=${token}`, {\\n headers: { \\\"x-forwarded-for\\\": uniqueIp() },\\n })\\n );\\n expect(res.status).toBe(302);\\n expect(sessionTokenFrom(res).length).toBeGreaterThan(0);\\n\\n const again = await app.fetch(\\n new Request(`http://localhost/auth/verify?token=${token}`, {\\n headers: { \\\"x-forwarded-for\\\": uniqueIp() },\\n })\\n );\\n expect(again.status).toBe(400);\\n });\\n\\n it(\\\"rejects expired login tokens\\\", async () => {\\n const email = uniqueEmail();\\n await createUser(email, null);\\n const token = await createLoginToken(email);\\n const { loginTokens } = await import(\\\"../src/db/schema-auth\\\");\\n await db\\n .update(loginTokens)\\n .set({ expiresAt: new Date(Date.now() - 1000).toISOString() })\\n .where(eq(loginTokens.tokenHash, hashToken(token!)));\\n expect(await consumeLoginToken(token!)).toBeNull();\\n });\\n});\\n\\ndescribe(\\\"login-link delivery seam\\\", () => {\\n it(\\\"refuses to emit links unless NODE_ENV is explicitly development\\\", async () => {\\n const original = process.env.NODE_ENV;\\n try {\\n delete process.env.NODE_ENV;\\n await expect(deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\")).rejects.toThrow(\\n \\\"not wired\\\"\\n );\\n process.env.NODE_ENV = \\\"production\\\";\\n await expect(deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\")).rejects.toThrow(\\n \\\"not wired\\\"\\n );\\n process.env.NODE_ENV = \\\"development\\\";\\n await deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\");\\n } finally {\\n if (original === undefined) delete process.env.NODE_ENV;\\n else process.env.NODE_ENV = original;\\n }\\n });\\n});\\n\\ndescribe(\\\"rate limiter\\\", () => {\\n it(\\\"enforces the window and resets after it passes\\\", () => {\\n const start = 1_000_000;\\n for (let i = 0; i < 3; i++) {\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + i)).toBe(true);\\n }\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + 3)).toBe(false);\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + 1001)).toBe(true);\\n });\\n\\n it(\\\"stays bounded under attacker-minted keys\\\", () => {\\n const start = 2_000_000;\\n // Well past the 10,000-window cap; must neither throw nor block fresh keys.\\n for (let i = 0; i < 10_500; i++) {\\n expect(rateLimit(`flood:${i}`, 3, 60_000, start + i)).toBe(true);\\n }\\n expect(rateLimit(\\\"flood:final\\\", 3, 60_000, start + 11_000)).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"csrfOptions\\\", () => {\\n it(\\\"pins the CSRF origin to APP_ORIGIN so browser form posts work behind a TLS proxy\\\", async () => {\\n process.env.APP_ORIGIN = \\\"https://app.example.com\\\";\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(csrfOptions()).toEqual({ origin: \\\"https://app.example.com\\\" });\\n const { Hono } = await import(\\\"hono\\\");\\n const { csrf } = await import(\\\"hono/csrf\\\");\\n const probe = new Hono();\\n probe.use(csrf(csrfOptions()));\\n probe.post(\\\"/x\\\", (c) => c.text(\\\"ok\\\"));\\n const form = (origin: string) =>\\n new Request(\\\"http://app.example.com/x\\\", {\\n method: \\\"POST\\\",\\n headers: { origin, \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\" },\\n body: \\\"a=1\\\",\\n });\\n // Request URL is http:// (what the app sees behind Caddy); the browser\\n // sends the https origin. Default csrf() rejects this; pinned passes.\\n expect((await probe.fetch(form(\\\"https://app.example.com\\\"))).status).toBe(200);\\n expect((await probe.fetch(form(\\\"https://evil.example\\\"))).status).toBe(403);\\n } finally {\\n delete process.env.APP_ORIGIN;\\n }\\n });\\n\\n it(\\\"keeps the same-origin default when APP_ORIGIN is unset\\\", async () => {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(csrfOptions()).toEqual({});\\n });\\n});\\n\\ndescribe(\\\"csrfOptions hardening\\\", () => {\\n it(\\\"rejects non-http(s) APP_ORIGIN (origin would serialize to null)\\\", async () => {\\n process.env.APP_ORIGIN = \\\"ftp://app.example.com\\\";\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(() => csrfOptions()).toThrow(\\\"http(s)\\\");\\n } finally {\\n delete process.env.APP_ORIGIN;\\n }\\n });\\n\\n it(\\\"fails loud when APP_ORIGIN is unset in production\\\", async () => {\\n const previous = process.env.NODE_ENV;\\n process.env.NODE_ENV = \\\"production\\\";\\n delete process.env.APP_ORIGIN;\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(() => csrfOptions()).toThrow(\\\"APP_ORIGIN must be set\\\");\\n } finally {\\n process.env.NODE_ENV = previous;\\n }\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/env.ts\",\"find\":\" DB_PATH: z.string().min(1).default(\\\"data/app.db\\\"),\",\"insert\":\" // Auth extension (shibumi add auth): canonical origin for login links,\\n // e.g. https://app.example.com. Required in production; development\\n // falls back to the request origin.\\n APP_ORIGIN: z.url().optional(),\"},{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { authRoutes } from \\\"./routes/auth\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/auth\\\", authRoutes);\"},{\"file\":\"src/db/index.ts\",\"find\":\"import * as schema from \\\"./schema\\\";\",\"insert\":\"import * as authSchema from \\\"./schema-auth\\\";\"},{\"file\":\"src/db/index.ts\",\"find\":\"export const db = drizzle(sqlite, { schema });\",\"replace\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } });\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Auth extension (bun run shibumi add auth): CSRF-protected and\\n // rate-limited; covered by test/auth.test.ts.\\n \\\"ALL /auth/*\\\",\\n \\\"POST /auth/register\\\",\\n \\\"POST /auth/login\\\",\\n \\\"POST /auth/login-link\\\",\\n \\\"GET /auth/verify\\\",\\n \\\"POST /auth/logout\\\",\\n \\\"GET /auth/me\\\",\"}],\"migration\":\"CREATE TABLE users (\\n id INTEGER PRIMARY KEY AUTOINCREMENT,\\n email TEXT NOT NULL UNIQUE CHECK (length(email) BETWEEN 3 AND 254),\\n password_hash TEXT,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE TABLE sessions (\\n token_hash TEXT PRIMARY KEY,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n expires_at TEXT NOT NULL,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX sessions_user_id ON sessions (user_id);\\nCREATE INDEX sessions_expires_at ON sessions (expires_at);\\n\\nCREATE TABLE login_tokens (\\n token_hash TEXT PRIMARY KEY,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n expires_at TEXT NOT NULL,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX login_tokens_user_id ON login_tokens (user_id);\\nCREATE INDEX login_tokens_expires_at ON login_tokens (expires_at);\\n\",\"agentsFile\":\"# Auth extension\\n\\nInstalled by `bun run shibumi add auth`. This project owns every file below; edit them like any other source.\\n\\n## Files\\n\\n- `src/lib/auth.ts`: users, sessions, login tokens, rate limiter, `requireAuth` / `optionalAuth` middleware, and the login-link delivery seam.\\n- `src/routes/auth.ts`: JSON endpoints mounted at `/auth`.\\n- `src/db/schema-auth.ts`: Drizzle schema for `users`, `sessions`, `login_tokens`.\\n- `src/db/migrations/<n>_auth.sql`: the tables, numbered into this project's migration stream at install time.\\n- `test/auth.test.ts`: register/login/session/login-link/CSRF/rate-limit coverage.\\n\\n## Config\\n\\nEditable knobs live in `src/config/auth.yaml` (bundled into the image at build; edit and re-deploy to apply): `session_days` (7), `login_link_minutes` (15), `password_min_length` (8), and the per-IP `register_rate_per_15min` / `login_rate_per_15min` / `login_link_rate_per_15min`. `src/lib/auth.ts` validates them at startup and refuses to boot on a bad value. Secondary per-email rate buckets, the body cap, and the tracked-window cap stay fixed in code.\\n\\n## Endpoints\\n\\n- `POST /auth/register` `{ email, password }` → 201, sets session cookie. Password 8 to 128 chars, hashed with `Bun.password` (argon2id).\\n- `POST /auth/login` `{ email, password }` → 200 or a uniform 401. Rate limited per IP + email (10 per 15 min).\\n- `POST /auth/login-link` `{ email }` → uniform 200 whether or not the account exists (no enumeration). Rate limited per IP (5 per 15 min).\\n- `GET /auth/verify?token=...` → consumes the single-use token (15 min expiry), sets session cookie, redirects to `/`.\\n- `POST /auth/logout` → destroys the session, clears the cookie.\\n- `GET /auth/me` → `{ user }` or `{ user: null }`.\\n\\n## Session model\\n\\n- Cookie `session`: HttpOnly, Secure, SameSite=Lax, Path=/, 7-day expiry. Browsers treat localhost as a secure context, so Secure works in development.\\n- The database stores sha256 hashes of session and login tokens, never the tokens. A leaked database cannot mint logins.\\n- Protect routes with `requireAuth` (401 when signed out) or `optionalAuth`:\\n\\n```ts\\nimport { requireAuth } from \\\"./lib/auth\\\";\\napp.use(\\\"/account/*\\\", requireAuth);\\n```\\n\\n## Environment\\n\\n- `APP_ORIGIN` (validated in `src/env.ts`): canonical origin for login links, e.g. `https://app.example.com`. Login-link building is fail-closed: only `NODE_ENV=development` falls back to the request origin; every other value (production, or unset) requires `APP_ORIGIN` and requires it to be `https`, so a poisoned Host header can never redirect tokens and tokens never ride plaintext. The generated Dockerfile sets `NODE_ENV=production`; keep it set in every deployment. Set the value on the server with `bun ship:env set APP_ORIGIN=https://app.example.com`, then `bun ship`.\\n\\n## Reserved emails\\n\\nIf `ADMIN_EMAILS` is set (by the admin extension), those addresses are privileged and cannot be created through `/auth/register` (it returns `403`). They can still sign in via the login link, which proves inbox control. This stops an attacker from registering the admin address first. No effect when `ADMIN_EMAILS` is unset.\\n\\n## Honeypot\\n\\n`register`, `login`, and `login-link` accept an optional decoy field named `website`. Real clients omit it (or send it empty); render it in HTML forms as a visually hidden input. A non-empty value marks the request as a bot: the response stays plausible (fake 201, uniform 401, uniform 200) while no account, session, or token is created.\\n\\n## CSRF and rate limiting\\n\\n- `hono/csrf` runs on every `/auth` mutation (Origin check on form-shaped posts); cross-origin JSON is stopped by the browser preflight.\\n- The rate limiter is in-memory and per-process, bounded at 10,000 tracked windows (oldest evicted beyond that). That matches the single-container deployment; counts reset on restart. Replace it before scaling to multiple processes.\\n- Rate keys prefer the socket peer address (`getConnInfo`). `x-forwarded-for` is trusted only when the direct peer is a local reverse proxy (the shibumi-server / Caddy deployment) or when there is no socket peer (test context), so a directly reachable app cannot be spoofed into rotating buckets.\\n\\n## Accepted tradeoffs\\n\\n- Registration returns 409 for a taken email (standard enumeration tradeoff; registration reveals existence by nature). The login and login-link flows stay uniform in their responses; a registered email still costs marginally more server time on login-link requests.\\n- `GET /auth/verify` changes state (sets the session); that is inherent to email login links. Tokens are single-use, 15-minute, and sha256-hashed at rest. A mail scanner that prefetches the link consumes the token before the user clicks; if that affects your users, serve a confirm page that POSTs the token instead.\\n- The login-link token rides in the URL query, so it can land in proxy access logs and browser history. Single use plus the 15-minute expiry bound the exposure; anyone who can read your access logs in real time has bigger levers.\\n- Rate limits: per IP+email and per email (50/15 min) on login, per IP (5/15 min) and per email (5/15 min, uniform response) on login-link. IP keys trust `x-forwarded-for` and are only meaningful behind the deployment proxy.\\n\\n## Wiring login-link delivery\\n\\n`deliverLoginLink` in `src/lib/auth.ts` logs the URL only when `NODE_ENV=development` and throws otherwise until wired. With the email extension installed:\\n\\n```ts\\nimport { sendEmail } from \\\"./email\\\";\\n\\nexport async function deliverLoginLink(email: string, url: string): Promise<void> {\\n await sendEmail({\\n to: email,\\n subject: \\\"Your login link\\\",\\n html: `<p><a href=\\\"${url}\\\">Log in</a> (expires in 15 minutes, single use).</p>`,\\n });\\n}\\n```\\n\\n## Removal\\n\\n`bun run shibumi remove auth` deletes the installed code and reverses the edits. Tables are never dropped by tooling; when the migration already ran somewhere, drop manually with:\\n\\n```sql\\nDROP TABLE login_tokens; DROP TABLE sessions; DROP TABLE users;\\n```\\n\",\"rootSection\":\"## Auth extension\\n\\nInstalled by `bun run shibumi add auth`; full guide in `agents/auth.md`.\\n\\n- Routes under `/auth` (register, login, login-link, verify, logout, me) live in `src/routes/auth.ts`; core logic and `requireAuth`/`optionalAuth` middleware in `src/lib/auth.ts`.\\n- Sessions: HttpOnly Secure cookie; the database stores sha256 token hashes in `sessions`, never tokens.\\n- Tables `users`, `sessions`, `login_tokens` come from the installed migration; Drizzle schema in `src/db/schema-auth.ts`.\\n- Login-link delivery is a seam (`deliverLoginLink`): development logs the URL, production throws until wired to the email extension (snippet in agents/auth.md).\\n- Honeypot: the optional `website` field on register/login/login-link marks bots; non-empty values get plausible responses with no work done.\\n- Removal deletes code only; tables stay. Manual drop statements are in agents/auth.md.\",\"removeNote\":\"Tables users, sessions, and login_tokens stay in app.db wherever the migration already ran. Drop them manually if unwanted: DROP TABLE login_tokens; DROP TABLE sessions; DROP TABLE users;\"},{\"name\":\"email\",\"title\":\"Email\",\"description\":\"Transactional email via Resend's HTTP API: send helper, safe templates, webhook verification\",\"version\":\"1.0.0\",\"requires\":null,\"env\":[\"RESEND_API_KEY\",\"EMAIL_FROM\",\"RESEND_WEBHOOK_SECRET\"],\"files\":[{\"to\":\"src/config/email.yaml\",\"content\":\"# Email extension config. Bundled into the image at build time; edit and\\n# re-deploy to apply. Validated at startup by src/lib/email.ts.\\n\\n# How far a Resend webhook timestamp may be from now, in seconds, before the\\n# signature is rejected (replay window). Lower is stricter.\\nwebhook_tolerance_seconds: 300\\n\"},{\"to\":\"src/lib/email.ts\",\"content\":\"// Transactional email via Resend's HTTP API. Installed by\\n// `bun run shibumi add email`; this project owns the file. One fetch, no SDK\\n// dependency. Env (validated in src/env.ts): RESEND_API_KEY and EMAIL_FROM\\n// are required at send time, RESEND_WEBHOOK_SECRET only for webhooks.\\nimport { createHmac, timingSafeEqual } from \\\"node:crypto\\\";\\nimport { loadEnv } from \\\"../env\\\";\\n// Editable knobs live in config/email.yaml; Bun bundles the parsed values into\\n// the image at build time. Validated here at module load.\\nimport rawEmailConfig from \\\"../config/email.yaml\\\";\\n\\nconst RESEND_ENDPOINT = \\\"https://api.resend.com/emails\\\";\\n\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`email config: ${key} must be a positive integer (config/email.yaml)`);\\n }\\n return value;\\n}\\n\\nconst emailConfig = (rawEmailConfig ?? {}) as Record<string, unknown>;\\nexport const WEBHOOK_TOLERANCE_SECONDS = positiveInt(emailConfig, \\\"webhook_tolerance_seconds\\\");\\n\\nexport interface SendEmailInput {\\n to: string;\\n subject: string;\\n html?: string;\\n text?: string;\\n /** Defaults to EMAIL_FROM. */\\n from?: string;\\n}\\n\\nexport interface SendEmailResult {\\n id: string;\\n}\\n\\nexport type Fetcher = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;\\n\\n// `fetcher` exists for tests; production callers use the default.\\nexport async function sendEmail(\\n input: SendEmailInput,\\n fetcher: Fetcher = fetch\\n): Promise<SendEmailResult> {\\n const env = loadEnv();\\n if (!env.RESEND_API_KEY) {\\n throw new Error(\\\"RESEND_API_KEY is not set. Add it to the environment before sending email.\\\");\\n }\\n const from = input.from ?? env.EMAIL_FROM;\\n if (!from) {\\n throw new Error(\\\"No sender address. Set EMAIL_FROM or pass `from` explicitly.\\\");\\n }\\n if (!input.html && !input.text) {\\n throw new Error(\\\"Provide html or text content for the email.\\\");\\n }\\n const response = await fetcher(RESEND_ENDPOINT, {\\n method: \\\"POST\\\",\\n headers: {\\n authorization: `Bearer ${env.RESEND_API_KEY}`,\\n \\\"content-type\\\": \\\"application/json\\\",\\n },\\n body: JSON.stringify({\\n from,\\n to: [input.to],\\n subject: input.subject,\\n html: input.html,\\n text: input.text,\\n }),\\n });\\n if (!response.ok) {\\n const detail = (await response.text().catch(() => \\\"\\\")).slice(0, 300);\\n throw new Error(`Resend rejected the send: ${response.status} ${detail}`);\\n }\\n const data = (await response.json()) as { id?: string };\\n if (!data.id) {\\n throw new Error(\\\"Resend responded without a message id.\\\");\\n }\\n return { id: data.id };\\n}\\n\\nexport function escapeHtml(value: string): string {\\n return value\\n .replaceAll(\\\"&\\\", \\\"&amp;\\\")\\n .replaceAll(\\\"<\\\", \\\"&lt;\\\")\\n .replaceAll(\\\">\\\", \\\"&gt;\\\")\\n .replaceAll('\\\"', \\\"&quot;\\\")\\n .replaceAll(\\\"'\\\", \\\"&#39;\\\");\\n}\\n\\n// Fills {{name}} placeholders, HTML-escaping every value. Throws on a\\n// placeholder without a value, and on placeholder names outside \\\\w+ (they\\n// would pass through silently), so typos fail in tests, not in inboxes.\\nexport function renderTemplate(template: string, vars: Record<string, string | number>): string {\\n for (const match of template.matchAll(/\\\\{\\\\{([^{}]*)\\\\}\\\\}/g)) {\\n if (!/^\\\\w+$/.test(match[1] ?? \\\"\\\")) {\\n throw new Error(`Invalid template placeholder ${match[0]}; names must match \\\\\\\\w+.`);\\n }\\n }\\n return template.replaceAll(/\\\\{\\\\{(\\\\w+)\\\\}\\\\}/g, (_whole, name: string) => {\\n const value = vars[name];\\n if (value === undefined) {\\n throw new Error(`Missing template variable \\\"${name}\\\".`);\\n }\\n return escapeHtml(String(value));\\n });\\n}\\n\\n// Verifies a Resend webhook (svix format): HMAC-SHA256 over\\n// \\\"<id>.<timestamp>.<rawBody>\\\" with the base64 part of the whsec_ secret,\\n// constant-time compare, 5-minute timestamp tolerance. Pass the raw request\\n// body string, not parsed JSON.\\nexport function verifyResendWebhook(\\n rawBody: string,\\n headers: Record<string, string | undefined>,\\n secret: string,\\n nowMs = Date.now()\\n): boolean {\\n const id = headers[\\\"svix-id\\\"];\\n const timestamp = headers[\\\"svix-timestamp\\\"];\\n const signatures = headers[\\\"svix-signature\\\"];\\n if (!id || !timestamp || !signatures) return false;\\n const seconds = Number(timestamp);\\n if (!Number.isFinite(seconds) || Math.abs(nowMs / 1000 - seconds) > WEBHOOK_TOLERANCE_SECONDS) return false;\\n const key = Buffer.from(secret.replace(/^whsec_/, \\\"\\\"), \\\"base64\\\");\\n if (key.length === 0) return false;\\n const expected = createHmac(\\\"sha256\\\", key).update(`${id}.${timestamp}.${rawBody}`).digest();\\n for (const candidate of signatures.split(\\\" \\\")) {\\n const [version, value] = candidate.split(\\\",\\\", 2);\\n if (version !== \\\"v1\\\" || !value) continue;\\n const provided = Buffer.from(value, \\\"base64\\\");\\n if (provided.length === expected.length && timingSafeEqual(provided, expected)) return true;\\n }\\n return false;\\n}\\n\"},{\"to\":\"test/email.test.ts\",\"content\":\"import { createHmac } from \\\"node:crypto\\\";\\nimport { describe, expect, it } from \\\"bun:test\\\";\\n\\n// loadEnv reads process.env on each call; set the email vars before import\\n// so sends are configured for the whole file.\\nprocess.env.RESEND_API_KEY = \\\"re_test_key\\\";\\nprocess.env.EMAIL_FROM = \\\"App <app@example.com>\\\";\\nconst { escapeHtml, renderTemplate, sendEmail, verifyResendWebhook } = await import(\\n \\\"../src/lib/email\\\"\\n);\\n\\ninterface RecordedRequest {\\n url: string;\\n init: RequestInit;\\n}\\n\\nfunction fetcherReturning(status: number, body: unknown, recorded: RecordedRequest[] = []) {\\n return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {\\n recorded.push({ url: String(url), init: init ?? {} });\\n return new Response(JSON.stringify(body), { status });\\n };\\n}\\n\\ndescribe(\\\"sendEmail\\\", () => {\\n it(\\\"posts the payload to Resend with the bearer key and returns the id\\\", async () => {\\n const recorded: RecordedRequest[] = [];\\n const result = await sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Hello\\\", html: \\\"<p>Hi</p>\\\" },\\n fetcherReturning(200, { id: \\\"email_123\\\" }, recorded)\\n );\\n expect(result.id).toBe(\\\"email_123\\\");\\n expect(recorded.length).toBe(1);\\n expect(recorded[0]!.url).toBe(\\\"https://api.resend.com/emails\\\");\\n const headers = recorded[0]!.init.headers as Record<string, string>;\\n expect(headers.authorization).toBe(\\\"Bearer re_test_key\\\");\\n const payload = JSON.parse(String(recorded[0]!.init.body));\\n expect(payload).toEqual({\\n from: \\\"App <app@example.com>\\\",\\n to: [\\\"user@example.com\\\"],\\n subject: \\\"Hello\\\",\\n html: \\\"<p>Hi</p>\\\",\\n });\\n });\\n\\n it(\\\"prefers an explicit from address\\\", async () => {\\n const recorded: RecordedRequest[] = [];\\n await sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Hi\\\", text: \\\"hi\\\", from: \\\"Other <other@example.com>\\\" },\\n fetcherReturning(200, { id: \\\"email_1\\\" }, recorded)\\n );\\n expect(JSON.parse(String(recorded[0]!.init.body)).from).toBe(\\\"Other <other@example.com>\\\");\\n });\\n\\n it(\\\"requires content and surfaces Resend rejections\\\", async () => {\\n await expect(\\n sendEmail({ to: \\\"user@example.com\\\", subject: \\\"Empty\\\" }, fetcherReturning(200, { id: \\\"x\\\" }))\\n ).rejects.toThrow(\\\"html or text\\\");\\n await expect(\\n sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Nope\\\", text: \\\"hi\\\" },\\n fetcherReturning(422, { message: \\\"invalid\\\" })\\n )\\n ).rejects.toThrow(\\\"422\\\");\\n });\\n});\\n\\ndescribe(\\\"renderTemplate\\\", () => {\\n it(\\\"fills variables and escapes HTML in values\\\", () => {\\n const html = renderTemplate(\\\"<p>Hello {{name}}, your code is {{code}}</p>\\\", {\\n name: '<b>\\\"Ada\\\" & Co</b>',\\n code: 1234,\\n });\\n expect(html).toBe(\\n \\\"<p>Hello &lt;b&gt;&quot;Ada&quot; &amp; Co&lt;/b&gt;, your code is 1234</p>\\\"\\n );\\n });\\n\\n it(\\\"throws on a missing variable\\\", () => {\\n expect(() => renderTemplate(\\\"Hi {{name}}\\\", {})).toThrow('Missing template variable \\\"name\\\"');\\n });\\n\\n it(\\\"throws on placeholder names that would silently pass through\\\", () => {\\n expect(() => renderTemplate(\\\"Hi {{first-name}}\\\", { name: \\\"x\\\" })).toThrow(\\\"Invalid template placeholder\\\");\\n });\\n\\n it(\\\"escapes all HTML-significant characters\\\", () => {\\n expect(escapeHtml(`&<>\\\"'`)).toBe(\\\"&amp;&lt;&gt;&quot;&#39;\\\");\\n });\\n});\\n\\ndescribe(\\\"verifyResendWebhook\\\", () => {\\n const secretBytes = Buffer.from(\\\"webhook-secret-key-for-tests\\\");\\n const secret = `whsec_${secretBytes.toString(\\\"base64\\\")}`;\\n\\n function sign(id: string, timestamp: string, body: string): string {\\n return createHmac(\\\"sha256\\\", secretBytes).update(`${id}.${timestamp}.${body}`).digest(\\\"base64\\\");\\n }\\n\\n it(\\\"accepts a valid signature and rejects tampered bodies\\\", () => {\\n const body = '{\\\"type\\\":\\\"email.delivered\\\"}';\\n const nowMs = 1_700_000_000_000;\\n const timestamp = String(nowMs / 1000);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": timestamp,\\n \\\"svix-signature\\\": `v1,${sign(\\\"msg_1\\\", timestamp, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(true);\\n expect(verifyResendWebhook('{\\\"type\\\":\\\"forged\\\"}', headers, secret, nowMs)).toBe(false);\\n });\\n\\n it(\\\"rejects stale timestamps and missing headers\\\", () => {\\n const body = \\\"{}\\\";\\n const nowMs = 1_700_000_000_000;\\n const staleTs = String(nowMs / 1000 - 600);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": staleTs,\\n \\\"svix-signature\\\": `v1,${sign(\\\"msg_1\\\", staleTs, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(false);\\n expect(verifyResendWebhook(body, {}, secret, nowMs)).toBe(false);\\n });\\n\\n it(\\\"accepts any valid entry in a multi-signature header\\\", () => {\\n const body = \\\"{}\\\";\\n const nowMs = 1_700_000_000_000;\\n const timestamp = String(nowMs / 1000);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": timestamp,\\n \\\"svix-signature\\\": `v1,${Buffer.from(\\\"wrong\\\").toString(\\\"base64\\\")} v1,${sign(\\\"msg_1\\\", timestamp, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(true);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/env.ts\",\"find\":\"const schema = z.object({\",\"insert\":\" // Email extension (shibumi add email): checked at send time, not boot.\\n RESEND_API_KEY: z.string().min(1).optional(),\\n EMAIL_FROM: z.string().min(3).optional(),\\n RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),\"}],\"migration\":null,\"agentsFile\":\"# Email extension\\n\\nInstalled by `bun run shibumi add email`. This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/email.ts`: `sendEmail`, `renderTemplate`, `escapeHtml`, `verifyResendWebhook`. Plain fetch to Resend's HTTP API; no SDK dependency.\\n- `test/email.test.ts`: send payload, template rendering and escaping, webhook signature coverage. Uses an injected fetcher; no network.\\n\\n## Config\\n\\n`src/config/email.yaml` (bundled at build) holds `webhook_tolerance_seconds` (default 300): how far a webhook timestamp may be from now before the signature is rejected. Validated at startup.\\n\\n## Environment\\n\\nValidated in `src/env.ts`, all optional at boot and checked at use:\\n\\n- `RESEND_API_KEY`: required to send. Set it on the server with `bun ship:env set RESEND_API_KEY=...` (never in code or git), then `bun ship`.\\n- `EMAIL_FROM`: default sender, e.g. `App <app@yourdomain.com>`. The domain must be verified in Resend.\\n- `RESEND_WEBHOOK_SECRET`: only for webhook verification (`whsec_...`).\\n\\n## Sending\\n\\n```ts\\nimport { renderTemplate, sendEmail } from \\\"./lib/email\\\";\\n\\nawait sendEmail({\\n to: \\\"user@example.com\\\",\\n subject: \\\"Welcome\\\",\\n html: renderTemplate(\\\"<p>Hello {{name}}</p>\\\", { name: user.name }),\\n});\\n```\\n\\n`renderTemplate` HTML-escapes every variable and throws on a missing one; never interpolate user input into email HTML directly.\\n\\n## Webhooks\\n\\nResend webhooks are svix-signed. Verify with the raw body string before parsing:\\n\\n```ts\\napp.post(\\\"/webhooks/resend\\\", async (c) => {\\n const raw = await c.req.text();\\n const env = loadEnv();\\n if (!env.RESEND_WEBHOOK_SECRET || !verifyResendWebhook(raw, {\\n \\\"svix-id\\\": c.req.header(\\\"svix-id\\\"),\\n \\\"svix-timestamp\\\": c.req.header(\\\"svix-timestamp\\\"),\\n \\\"svix-signature\\\": c.req.header(\\\"svix-signature\\\"),\\n }, env.RESEND_WEBHOOK_SECRET)) {\\n return c.json({ error: \\\"Invalid signature\\\" }, 401);\\n }\\n const event = JSON.parse(raw);\\n // handle event.type: email.delivered, email.bounced, ...\\n return c.json({ ok: true });\\n});\\n```\\n\\nSignature verification does not stop replays inside the 5-minute tolerance window: if a webhook triggers side effects, record processed `svix-id` values and skip duplicates.\\n\\n## Removal\\n\\n`bun run shibumi remove email` deletes the installed code and reverses the `src/env.ts` edit. No tables are involved.\\n\",\"rootSection\":\"## Email extension\\n\\nInstalled by `bun run shibumi add email`; full guide in `agents/email.md`.\\n\\n- `src/lib/email.ts`: `sendEmail` (plain fetch to Resend, no SDK), `renderTemplate` (HTML-escapes every variable), `verifyResendWebhook` (svix HMAC, constant-time).\\n- Env: `RESEND_API_KEY` and `EMAIL_FROM` required at send time, `RESEND_WEBHOOK_SECRET` for webhooks; all validated in `src/env.ts`, none required at boot.\\n- Verify webhooks against the raw body string before parsing (snippet in agents/email.md).\\n- No tables; removal deletes the code and reverses the env edit.\",\"removeNote\":\"No tables are involved; remove any RESEND_* variables from the deployment environment when no longer needed.\"},{\"name\":\"uploads\",\"title\":\"Uploads\",\"description\":\"Authenticated file uploads: validated multipart, content-addressed storage on the persistent volume, owner-scoped serving\",\"version\":\"1.0.1\",\"requires\":\"database\",\"dependsOn\":[\"auth\"],\"env\":[],\"files\":[{\"to\":\"src/config/uploads.yaml\",\"content\":\"# Uploads extension config. Edit and re-deploy (bun ship) to apply; the values\\n# are bundled into the image at build time. These are security limits, so keep\\n# them tight. src/lib/uploads.ts validates them at startup and refuses to boot\\n# on a bad value.\\n\\n# Largest single file, in MiB.\\nmax_file_mib: 5\\n\\n# Files accepted per upload request.\\nmax_files_per_request: 5\\n\\n# Total stored bytes per user, in MiB.\\nuser_quota_mib: 100\\n\\n# Upload requests allowed per user per 15 minutes.\\nrate_limit_per_15min: 30\\n\"},{\"to\":\"src/db/schema-uploads.ts\",\"content\":\"import { sql } from \\\"drizzle-orm\\\";\\nimport { integer, sqliteTable, text } from \\\"drizzle-orm/sqlite-core\\\";\\nimport { users } from \\\"./schema-auth\\\";\\n\\n// Owned by the uploads extension (bun run shibumi add uploads). Metadata only;\\n// bytes live on the persistent volume next to app.db. stored_name is content\\n// addressed (sha256 + sniffed extension), never a user filename.\\nexport const uploads = sqliteTable(\\\"uploads\\\", {\\n id: integer(\\\"id\\\").primaryKey({ autoIncrement: true }),\\n storedName: text(\\\"stored_name\\\").notNull(),\\n originalName: text(\\\"original_name\\\").notNull(),\\n contentType: text(\\\"content_type\\\").notNull(),\\n size: integer(\\\"size\\\").notNull(),\\n sha256: text(\\\"sha256\\\").notNull(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\"},{\"to\":\"src/lib/uploads.ts\",\"content\":\"// Upload storage: validate, content-address, persist, serve, delete.\\n// Installed by `bun run shibumi add uploads` (needs the auth extension).\\n// This project owns the file. Full guide: agents/uploads.md.\\n//\\n// Invariants:\\n// - A file's type is decided by sniffing its leading bytes, never by the\\n// client-supplied filename or Content-Type. Anything not on the allowlist\\n// is rejected.\\n// - On-disk names are sha256(content) + the sniffed extension, so a filename\\n// can never contain a path separator or traversal segment, and identical\\n// bytes are stored once.\\n// - Bytes live under <db-dir>/uploads, i.e. the persistent /data volume in the\\n// container; metadata lives in app.db. The two are reconciled on delete.\\nimport { createHash, randomUUID } from \\\"node:crypto\\\";\\nimport { existsSync, lstatSync, mkdirSync, renameSync, rmSync } from \\\"node:fs\\\";\\nimport { dirname, join, resolve, sep } from \\\"node:path\\\";\\nimport { and, eq, sql } from \\\"drizzle-orm\\\";\\nimport { db } from \\\"../db\\\";\\nimport { uploads } from \\\"../db/schema-uploads\\\";\\nimport { loadEnv } from \\\"../env\\\";\\n// Editable knobs live in config/uploads.yaml; Bun bundles the parsed values\\n// into the image at build time. Edit that file and re-deploy to change limits.\\nimport rawUploadsConfig from \\\"../config/uploads.yaml\\\";\\n\\n// A bad edit (missing, non-numeric, non-positive, non-integer) throws here at\\n// module load, so the container fails its health check and the previous\\n// deployment stays live rather than serving with a disabled limit.\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`uploads config: ${key} must be a positive integer (config/uploads.yaml)`);\\n }\\n return value;\\n}\\n\\nconst uploadsConfig = (rawUploadsConfig ?? {}) as Record<string, unknown>;\\nconst MIB = 1024 * 1024;\\n\\nexport const MAX_FILE_BYTES = positiveInt(uploadsConfig, \\\"max_file_mib\\\") * MIB;\\nexport const MAX_FILES_PER_REQUEST = positiveInt(uploadsConfig, \\\"max_files_per_request\\\");\\n// Per-user ceiling on total stored bytes, so a self-registered account cannot\\n// fill the volume. Dedup means shared blobs are counted per referencing row,\\n// which is the conservative (higher) number.\\nexport const USER_QUOTA_BYTES = positiveInt(uploadsConfig, \\\"user_quota_mib\\\") * MIB;\\nexport const UPLOAD_RATE_LIMIT = positiveInt(uploadsConfig, \\\"rate_limit_per_15min\\\");\\nexport const UPLOAD_RATE_WINDOW_MS = 15 * 60 * 1000;\\n\\ninterface AllowedType {\\n contentType: string;\\n extension: string;\\n matches: (bytes: Uint8Array) => boolean;\\n}\\n\\nfunction startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {\\n if (bytes.length < offset + signature.length) return false;\\n return signature.every((byte, index) => bytes[offset + index] === byte);\\n}\\n\\n// Allowlist by magic bytes. Extend deliberately; every entry must have a\\n// signature so type is proven, not asserted.\\nconst ALLOWED_TYPES: AllowedType[] = [\\n { contentType: \\\"image/png\\\", extension: \\\"png\\\", matches: (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },\\n { contentType: \\\"image/jpeg\\\", extension: \\\"jpg\\\", matches: (b) => startsWith(b, [0xff, 0xd8, 0xff]) },\\n { contentType: \\\"image/gif\\\", extension: \\\"gif\\\", matches: (b) => startsWith(b, [0x47, 0x49, 0x46, 0x38]) },\\n {\\n contentType: \\\"image/webp\\\",\\n extension: \\\"webp\\\",\\n matches: (b) => startsWith(b, [0x52, 0x49, 0x46, 0x46]) && startsWith(b, [0x57, 0x45, 0x42, 0x50], 8),\\n },\\n { contentType: \\\"application/pdf\\\", extension: \\\"pdf\\\", matches: (b) => startsWith(b, [0x25, 0x50, 0x44, 0x46]) },\\n];\\n\\nexport function sniffType(bytes: Uint8Array): AllowedType | null {\\n return ALLOWED_TYPES.find((type) => type.matches(bytes)) ?? null;\\n}\\n\\nexport function uploadsDir(): string {\\n const env = loadEnv();\\n return join(dirname(env.DB_PATH), \\\"uploads\\\");\\n}\\n\\n// Resolve a stored name to an absolute path, refusing anything that is not a\\n// bare content-addressed name or that escapes the uploads directory.\\nconst STORED_NAME = /^[a-f0-9]{64}\\\\.[a-z0-9]+$/;\\nexport function resolveStored(storedName: string): string | null {\\n if (!STORED_NAME.test(storedName)) return null;\\n const base = resolve(uploadsDir());\\n const target = resolve(base, storedName);\\n if (target !== join(base, storedName) || !target.startsWith(base + sep)) return null;\\n return target;\\n}\\n\\nexport interface StoredUpload {\\n id: number;\\n storedName: string;\\n originalName: string;\\n contentType: string;\\n size: number;\\n sha256: string;\\n}\\n\\nexport interface RejectedUpload {\\n originalName: string;\\n reason: string;\\n}\\n\\nexport interface SaveResult {\\n saved: StoredUpload[];\\n rejected: RejectedUpload[];\\n}\\n\\nfunction sanitizeOriginalName(name: string): string {\\n // Kept for display only; never used on disk. Strip path separators and\\n // control chars (incl. CR/LF so it is safe in a Content-Disposition header),\\n // keep ordinary filename characters like \\\".\\\", bound the length.\\n const base =\\n name\\n .replace(/[/\\\\\\\\]/g, \\\"\\\")\\n .replace(/[\\\\x00-\\\\x1f\\\\x7f]/g, \\\"\\\")\\n .trim() || \\\"file\\\";\\n return base.slice(0, 255);\\n}\\n\\n// Validates and stores one already-read buffer. Exported for direct testing.\\nexport async function saveBuffer(\\n bytes: Uint8Array,\\n originalName: string,\\n userId: number\\n): Promise<StoredUpload> {\\n if (bytes.length === 0) throw new Error(\\\"empty file\\\");\\n if (bytes.length > MAX_FILE_BYTES) throw new Error(`file exceeds ${MAX_FILE_BYTES} bytes`);\\n const type = sniffType(bytes);\\n if (!type) throw new Error(\\\"unsupported file type\\\");\\n\\n const sha256 = createHash(\\\"sha256\\\").update(bytes).digest(\\\"hex\\\");\\n const storedName = `${sha256}.${type.extension}`;\\n const target = resolveStored(storedName);\\n if (!target) throw new Error(\\\"could not resolve a safe storage path\\\");\\n\\n const dir = uploadsDir();\\n mkdirSync(dir, { recursive: true });\\n // The storage root must be a real directory, never a symlink another\\n // principal could repoint outside the volume.\\n if (lstatSync(dir).isSymbolicLink()) throw new Error(\\\"uploads directory is a symlink\\\");\\n // Content-addressed: if the bytes already exist on disk, reuse them.\\n // Otherwise write to a unique temp file and atomically rename into place,\\n // so a crash mid-write never leaves a partial blob under the final name.\\n if (!existsSync(target)) {\\n const tmp = `${target}.tmp-${randomUUID()}`;\\n try {\\n await Bun.write(tmp, bytes);\\n renameSync(tmp, target);\\n } finally {\\n if (existsSync(tmp)) rmSync(tmp, { force: true });\\n }\\n }\\n\\n const originalNameSafe = sanitizeOriginalName(originalName);\\n const rows = await db\\n .insert(uploads)\\n .values({\\n storedName,\\n originalName: originalNameSafe,\\n contentType: type.contentType,\\n size: bytes.length,\\n sha256,\\n userId,\\n })\\n .returning();\\n const row = rows[0]!;\\n return {\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n };\\n}\\n\\nexport async function userUsageBytes(userId: number): Promise<number> {\\n const row = await db\\n .select({ total: sql<number>`coalesce(sum(${uploads.size}), 0)` })\\n .from(uploads)\\n .where(eq(uploads.userId, userId));\\n return Number(row[0]?.total ?? 0);\\n}\\n\\n// Serialize a user's uploads so the quota read-modify-write cannot interleave\\n// across concurrent requests (single-process container). Each user gets a\\n// promise chain; entries drop out once the chain drains.\\nconst userLocks = new Map<number, Promise<unknown>>();\\nexport function saveFiles(files: File[], userId: number): Promise<SaveResult> {\\n const run = (userLocks.get(userId) ?? Promise.resolve()).then(\\n () => saveFilesLocked(files, userId),\\n () => saveFilesLocked(files, userId)\\n );\\n userLocks.set(userId, run);\\n void run.finally(() => {\\n if (userLocks.get(userId) === run) userLocks.delete(userId);\\n });\\n return run;\\n}\\n\\nasync function saveFilesLocked(files: File[], userId: number): Promise<SaveResult> {\\n if (files.length === 0) throw new Error(\\\"no files provided\\\");\\n if (files.length > MAX_FILES_PER_REQUEST) {\\n throw new Error(`too many files (max ${MAX_FILES_PER_REQUEST} per request)`);\\n }\\n const saved: StoredUpload[] = [];\\n const rejected: RejectedUpload[] = [];\\n // Serialized above, so this snapshot is stable for the batch.\\n let usage = await userUsageBytes(userId);\\n for (const file of files) {\\n const originalName = sanitizeOriginalName(file.name || \\\"file\\\");\\n try {\\n // Enforce the size limit before buffering the whole file.\\n if (file.size > MAX_FILE_BYTES) throw new Error(`file exceeds ${MAX_FILE_BYTES} bytes`);\\n if (usage + file.size > USER_QUOTA_BYTES) throw new Error(\\\"storage quota exceeded\\\");\\n const bytes = new Uint8Array(await file.arrayBuffer());\\n const stored = await saveBuffer(bytes, originalName, userId);\\n usage += stored.size;\\n saved.push(stored);\\n } catch (error) {\\n rejected.push({ originalName, reason: error instanceof Error ? error.message : \\\"rejected\\\" });\\n }\\n }\\n return { saved, rejected };\\n}\\n\\nexport async function listUploads(userId: number): Promise<StoredUpload[]> {\\n const rows = await db.select().from(uploads).where(eq(uploads.userId, userId));\\n return rows.map((row) => ({\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n }));\\n}\\n\\nexport async function getUpload(id: number, userId: number): Promise<(StoredUpload & { path: string }) | null> {\\n const rows = await db\\n .select()\\n .from(uploads)\\n .where(and(eq(uploads.id, id), eq(uploads.userId, userId)));\\n const row = rows[0];\\n if (!row) return null;\\n const path = resolveStored(row.storedName);\\n if (!path) return null;\\n return {\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n path,\\n };\\n}\\n\\nexport async function deleteUpload(id: number, userId: number): Promise<boolean> {\\n const rows = await db\\n .delete(uploads)\\n .where(and(eq(uploads.id, id), eq(uploads.userId, userId)))\\n .returning();\\n const row = rows[0];\\n if (!row) return false;\\n // Only remove the bytes when no other row (any user) references the same\\n // content-addressed blob.\\n const others = await db.select({ id: uploads.id }).from(uploads).where(eq(uploads.storedName, row.storedName));\\n if (others.length === 0) {\\n const path = resolveStored(row.storedName);\\n if (path && existsSync(path)) await Bun.file(path).delete();\\n }\\n return true;\\n}\\n\"},{\"to\":\"src/routes/uploads.ts\",\"content\":\"// Upload routes, mounted at /uploads by the installer. Every route requires a\\n// session (uploads has no unauthenticated endpoints); CSRF covers mutations.\\n// Files are owned by the uploading user; serving is scoped to the owner.\\nimport { Hono } from \\\"hono\\\";\\nimport type { Context } from \\\"hono\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { csrfOptions, rateLimit, requireAuth, type AuthEnv } from \\\"../lib/auth\\\";\\nimport {\\n MAX_FILES_PER_REQUEST,\\n MAX_FILE_BYTES,\\n UPLOAD_RATE_LIMIT,\\n UPLOAD_RATE_WINDOW_MS,\\n deleteUpload,\\n getUpload,\\n listUploads,\\n saveFiles,\\n} from \\\"../lib/uploads\\\";\\n\\nexport const uploadRoutes = new Hono<AuthEnv>();\\n\\n// CSRF first (refuse cross-origin mutations before any session work), then a\\n// required session. Two middleware registrations, so the mounted surface\\n// carries two `ALL /uploads/*` entries.\\nuploadRoutes.use(csrf(csrfOptions()));\\nuploadRoutes.use(requireAuth);\\n\\nfunction idParam(c: Context): number | null {\\n const raw = c.req.param(\\\"id\\\") ?? \\\"\\\";\\n if (!/^\\\\d+$/.test(raw)) return null;\\n const id = Number(raw);\\n return Number.isSafeInteger(id) ? id : null;\\n}\\n\\nuploadRoutes.post(\\\"/\\\", async (c) => {\\n // Keyed on the authenticated user, so it needs no forwarded-IP trust.\\n if (!rateLimit(`uploads:${c.get(\\\"user\\\").id}`, UPLOAD_RATE_LIMIT, UPLOAD_RATE_WINDOW_MS)) {\\n return c.json({ error: \\\"Too many uploads. Try again later.\\\" }, 429);\\n }\\n let form: FormData;\\n try {\\n form = await c.req.formData();\\n } catch {\\n return c.json({ error: \\\"Expected multipart/form-data.\\\" }, 400);\\n }\\n const files = form.getAll(\\\"file\\\").filter((entry): entry is File => entry instanceof File);\\n if (files.length === 0) {\\n return c.json({ error: \\\"Attach at least one file in the 'file' field.\\\" }, 400);\\n }\\n if (files.length > MAX_FILES_PER_REQUEST) {\\n return c.json({ error: `Too many files (max ${MAX_FILES_PER_REQUEST}).` }, 400);\\n }\\n const result = await saveFiles(files, c.get(\\\"user\\\").id);\\n // All rejected and nothing saved is a client error; a partial success still\\n // reports which files were refused and why.\\n const status = result.saved.length === 0 ? 400 : 201;\\n return c.json(result, status);\\n});\\n\\nuploadRoutes.get(\\\"/\\\", async (c) => {\\n return c.json({ uploads: await listUploads(c.get(\\\"user\\\").id) });\\n});\\n\\nuploadRoutes.get(\\\"/:id\\\", async (c) => {\\n const id = idParam(c);\\n if (id === null) return c.json({ error: \\\"Invalid id.\\\" }, 400);\\n const upload = await getUpload(id, c.get(\\\"user\\\").id);\\n if (!upload) return c.json({ error: \\\"Not found.\\\" }, 404);\\n const file = Bun.file(upload.path);\\n if (!(await file.exists())) return c.json({ error: \\\"File is missing on disk.\\\" }, 410);\\n return new Response(file, {\\n headers: {\\n \\\"content-type\\\": upload.contentType,\\n \\\"content-length\\\": String(upload.size),\\n // Never render untrusted uploads inline; force a download. nosniff is\\n // also set globally, but pin it here so serving stays safe even if the\\n // app's header middleware changes.\\n \\\"content-disposition\\\": `attachment; filename=\\\"${upload.originalName.replace(/\\\"/g, \\\"\\\")}\\\"`,\\n \\\"x-content-type-options\\\": \\\"nosniff\\\",\\n \\\"cache-control\\\": \\\"private, no-store\\\",\\n },\\n });\\n});\\n\\nuploadRoutes.delete(\\\"/:id\\\", async (c) => {\\n const id = idParam(c);\\n if (id === null) return c.json({ error: \\\"Invalid id.\\\" }, 400);\\n const removed = await deleteUpload(id, c.get(\\\"user\\\").id);\\n if (!removed) return c.json({ error: \\\"Not found.\\\" }, 404);\\n return c.json({ ok: true });\\n});\\n\\nexport const UPLOAD_LIMITS = { maxFiles: MAX_FILES_PER_REQUEST, maxBytes: MAX_FILE_BYTES };\\n\"},{\"to\":\"test/uploads.test.ts\",\"content\":\"import { afterAll, describe, expect, it } from \\\"bun:test\\\";\\nimport { existsSync, mkdtempSync, rmSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\n// The db module opens DB_PATH at import time; point it at a scratch database\\n// whose directory also becomes the uploads root.\\nconst scratch = mkdtempSync(join(tmpdir(), \\\"uploads-test-\\\"));\\nprocess.env.DB_PATH = join(scratch, \\\"app.db\\\");\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { createUser, createSession, SESSION_COOKIE } = await import(\\\"../src/lib/auth\\\");\\nconst {\\n MAX_FILE_BYTES,\\n USER_QUOTA_BYTES,\\n deleteUpload,\\n resolveStored,\\n saveBuffer,\\n saveFiles,\\n sniffType,\\n uploadsDir,\\n userUsageBytes,\\n} = await import(\\\"../src/lib/uploads\\\");\\nconst { db } = await import(\\\"../src/db\\\");\\nconst { uploads } = await import(\\\"../src/db/schema-uploads\\\");\\nawait applyMigrations(sqlite);\\n\\nafterAll(() => rmSync(scratch, { recursive: true, force: true }));\\n\\nconst PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);\\nconst PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]);\\n\\nlet sessionCounter = 0;\\nasync function freshSession(): Promise<{ userId: number; cookie: string }> {\\n sessionCounter += 1;\\n const user = await createUser(`up${sessionCounter}-${Date.now()}@example.com`, \\\"password123\\\");\\n const token = await createSession(user.id);\\n return { userId: user.id, cookie: `${SESSION_COOKIE}=${token}` };\\n}\\n\\nfunction multipart(files: Array<{ name: string; bytes: Uint8Array; type?: string }>): FormData {\\n const form = new FormData();\\n for (const f of files) {\\n form.append(\\\"file\\\", new File([f.bytes as BlobPart], f.name, { type: f.type ?? \\\"application/octet-stream\\\" }));\\n }\\n return form;\\n}\\n\\nasync function upload(cookie: string, form: FormData): Promise<Response> {\\n return app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", {\\n method: \\\"POST\\\",\\n headers: { cookie, origin: \\\"http://localhost\\\" },\\n body: form,\\n })\\n );\\n}\\n\\ndescribe(\\\"type sniffing\\\", () => {\\n it(\\\"recognizes allowed types by magic bytes and rejects others\\\", () => {\\n expect(sniffType(PNG)?.contentType).toBe(\\\"image/png\\\");\\n expect(sniffType(PDF)?.contentType).toBe(\\\"application/pdf\\\");\\n expect(sniffType(new Uint8Array([0x3c, 0x73, 0x76, 0x67]))).toBeNull(); // <svg\\n expect(sniffType(new Uint8Array([0x4d, 0x5a]))).toBeNull(); // PE\\n });\\n});\\n\\ndescribe(\\\"resolveStored\\\", () => {\\n it(\\\"accepts content-addressed names and rejects traversal or arbitrary names\\\", () => {\\n const hex = \\\"a\\\".repeat(64);\\n expect(resolveStored(`${hex}.png`)).toContain(uploadsDir());\\n expect(resolveStored(\\\"../secret.png\\\")).toBeNull();\\n expect(resolveStored(\\\"evil.png\\\")).toBeNull();\\n expect(resolveStored(`${hex}.png/../../etc/passwd`)).toBeNull();\\n expect(resolveStored(`${hex}`)).toBeNull();\\n });\\n});\\n\\ndescribe(\\\"saveBuffer\\\", () => {\\n it(\\\"stores content-addressed and dedupes identical bytes\\\", async () => {\\n const { userId } = await freshSession();\\n const a = await saveBuffer(PNG, \\\"one.png\\\", userId);\\n const b = await saveBuffer(PNG, \\\"two.png\\\", userId);\\n expect(a.storedName).toBe(b.storedName);\\n expect(a.storedName).toMatch(/^[a-f0-9]{64}\\\\.png$/);\\n expect(existsSync(resolveStored(a.storedName)!)).toBe(true);\\n });\\n\\n it(\\\"rejects empty, oversize, and unknown-type buffers\\\", async () => {\\n const { userId } = await freshSession();\\n await expect(saveBuffer(new Uint8Array(0), \\\"empty.png\\\", userId)).rejects.toThrow(\\\"empty\\\");\\n await expect(saveBuffer(new Uint8Array(MAX_FILE_BYTES + 1).fill(0x89), \\\"big.png\\\", userId)).rejects.toThrow(\\n \\\"exceeds\\\"\\n );\\n await expect(saveBuffer(new Uint8Array([1, 2, 3, 4, 5]), \\\"mystery.bin\\\", userId)).rejects.toThrow(\\n \\\"unsupported\\\"\\n );\\n });\\n});\\n\\ndescribe(\\\"routes\\\", () => {\\n it(\\\"requires a session\\\", async () => {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", { headers: { \\\"x-forwarded-for\\\": \\\"10.9.9.9\\\" } })\\n );\\n expect(res.status).toBe(401);\\n });\\n\\n it(\\\"blocks cross-origin uploads (CSRF)\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", {\\n method: \\\"POST\\\",\\n headers: { cookie, origin: \\\"https://evil.example\\\" },\\n body: multipart([{ name: \\\"a.png\\\", bytes: PNG }]),\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"uploads, lists, downloads (as attachment), and deletes, owner-scoped\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await upload(cookie, multipart([{ name: \\\"photo.png\\\", bytes: PNG }]));\\n expect(res.status).toBe(201);\\n const body = (await res.json()) as { saved: Array<{ id: number; contentType: string }>; rejected: unknown[] };\\n expect(body.saved.length).toBe(1);\\n expect(body.rejected.length).toBe(0);\\n const id = body.saved[0]!.id;\\n\\n const list = await app.fetch(new Request(\\\"http://localhost/uploads\\\", { headers: { cookie } }));\\n expect(((await list.json()) as { uploads: unknown[] }).uploads.length).toBe(1);\\n\\n const download = await app.fetch(new Request(`http://localhost/uploads/${id}`, { headers: { cookie } }));\\n expect(download.status).toBe(200);\\n expect(download.headers.get(\\\"content-type\\\")).toBe(\\\"image/png\\\");\\n expect(download.headers.get(\\\"content-disposition\\\")).toContain(\\\"attachment\\\");\\n\\n // A different user cannot see or fetch it.\\n const other = await freshSession();\\n const otherGet = await app.fetch(\\n new Request(`http://localhost/uploads/${id}`, { headers: { cookie: other.cookie } })\\n );\\n expect(otherGet.status).toBe(404);\\n\\n const del = await app.fetch(\\n new Request(`http://localhost/uploads/${id}`, {\\n method: \\\"DELETE\\\",\\n headers: { cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(del.status).toBe(200);\\n });\\n\\n it(\\\"rejects a disallowed type in multipart with a reason\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await upload(cookie, multipart([{ name: \\\"script.svg\\\", bytes: new Uint8Array([0x3c, 0x73, 0x76, 0x67]) }]));\\n expect(res.status).toBe(400);\\n const body = (await res.json()) as { saved: unknown[]; rejected: Array<{ reason: string }> };\\n expect(body.saved.length).toBe(0);\\n expect(body.rejected[0]!.reason).toContain(\\\"unsupported\\\");\\n });\\n\\n it(\\\"caps the number of files per request\\\", async () => {\\n const { cookie } = await freshSession();\\n const many = Array.from({ length: 6 }, (_, i) => ({ name: `f${i}.png`, bytes: PNG }));\\n const res = await upload(cookie, multipart(many));\\n expect(res.status).toBe(400);\\n expect(((await res.json()) as { error: string }).error).toContain(\\\"Too many\\\");\\n });\\n});\\n\\ndescribe(\\\"quota\\\", () => {\\n it(\\\"rejects an upload that would exceed the per-user byte quota\\\", async () => {\\n const { userId } = await freshSession();\\n // Seed usage at the quota with a metadata row (no bytes on disk needed).\\n await db.insert(uploads).values({\\n storedName: `${\\\"b\\\".repeat(64)}.png`,\\n originalName: \\\"seed.png\\\",\\n contentType: \\\"image/png\\\",\\n size: USER_QUOTA_BYTES,\\n sha256: \\\"b\\\".repeat(64),\\n userId,\\n });\\n expect(await userUsageBytes(userId)).toBe(USER_QUOTA_BYTES);\\n const result = await saveFiles([new File([PNG as BlobPart], \\\"x.png\\\", { type: \\\"image/png\\\" })], userId);\\n expect(result.saved.length).toBe(0);\\n expect(result.rejected[0]!.reason).toContain(\\\"quota\\\");\\n });\\n});\\n\\ndescribe(\\\"rate limiting\\\", () => {\\n it(\\\"429s after the per-user upload limit\\\", async () => {\\n const { cookie } = await freshSession();\\n let limited = false;\\n for (let i = 0; i < 31; i++) {\\n const res = await upload(cookie, multipart([{ name: \\\"a.png\\\", bytes: PNG }]));\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"blob lifecycle\\\", () => {\\n it(\\\"keeps the blob while another row references it, deletes when last goes\\\", async () => {\\n const first = await freshSession();\\n const second = await freshSession();\\n const a = await saveBuffer(PDF, \\\"a.pdf\\\", first.userId);\\n const b = await saveBuffer(PDF, \\\"b.pdf\\\", second.userId);\\n expect(a.storedName).toBe(b.storedName);\\n const path = resolveStored(a.storedName)!;\\n\\n expect(await deleteUpload(a.id, first.userId)).toBe(true);\\n expect(existsSync(path)).toBe(true); // second row still references it\\n expect(await deleteUpload(b.id, second.userId)).toBe(true);\\n expect(existsSync(path)).toBe(false);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { uploadRoutes } from \\\"./routes/uploads\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/uploads\\\", uploadRoutes);\"},{\"file\":\"src/db/index.ts\",\"find\":\"import * as authSchema from \\\"./schema-auth\\\";\",\"insert\":\"import * as uploadsSchema from \\\"./schema-uploads\\\";\"},{\"file\":\"src/db/index.ts\",\"find\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } });\",\"replace\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema, ...uploadsSchema } });\"},{\"file\":\"src/server.ts\",\"find\":\" maxRequestBodySize: 1024 * 1024,\",\"replace\":\" // Fixed server ceiling for the uploads extension; generous over the configurable per-file limits.\\n maxRequestBodySize: 55 * 1024 * 1024,\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Uploads extension (bun run shibumi add uploads): every route is\\n // session-guarded and CSRF-protected; covered by test/uploads.test.ts.\\n // Two ALL entries: the CSRF and requireAuth middlewares.\\n \\\"ALL /uploads/*\\\",\\n \\\"ALL /uploads/*\\\",\\n \\\"POST /uploads\\\",\\n \\\"GET /uploads\\\",\\n \\\"GET /uploads/:id\\\",\\n \\\"DELETE /uploads/:id\\\",\"}],\"migration\":\"CREATE TABLE uploads (\\n id INTEGER PRIMARY KEY AUTOINCREMENT,\\n -- Content-addressed name on disk (sha256 hex + sniffed extension); never a\\n -- user-supplied filename.\\n stored_name TEXT NOT NULL,\\n original_name TEXT NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255),\\n content_type TEXT NOT NULL,\\n size INTEGER NOT NULL CHECK (size >= 0),\\n sha256 TEXT NOT NULL,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX uploads_user_id ON uploads (user_id);\\nCREATE INDEX uploads_stored_name ON uploads (stored_name);\\n\",\"agentsFile\":\"# Uploads extension\\n\\nInstalled by `bun run shibumi add uploads`. Needs the auth extension (every route requires a session). This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/uploads.ts`: validation, content-addressed storage, listing, owner-scoped retrieval, deletion.\\n- `src/routes/uploads.ts`: JSON + file endpoints mounted at `/uploads`.\\n- `src/db/schema-uploads.ts`: Drizzle schema for the `uploads` metadata table.\\n- `src/db/migrations/<n>_uploads.sql`: the table, numbered into this project's migration stream at install time.\\n- `test/uploads.test.ts`: validation, storage, serving, deletion, and traversal-safety coverage.\\n\\n## Endpoints (all require a session)\\n\\n- `POST /uploads` multipart form, field `file` (repeatable) → `{ saved, rejected }`. 201 when anything saved, 400 when everything was rejected. CSRF protected.\\n- `GET /uploads` → `{ uploads }` for the current user.\\n- `GET /uploads/:id` → the bytes, owner-scoped, `Content-Disposition: attachment` (never inline), `Cache-Control: private, no-store`.\\n- `DELETE /uploads/:id` → removes the row; the blob is deleted only when no other row references it. CSRF protected.\\n\\n## Validation and storage\\n\\n- Type is decided by sniffing magic bytes, never the client filename or `Content-Type`. Allowlist: PNG, JPEG, GIF, WebP, PDF. Extend `ALLOWED_TYPES` in `src/lib/uploads.ts`; every entry must carry a byte signature.\\n- Limits live in `src/config/uploads.yaml` (bundled at build): `max_file_mib` (default 5), `max_files_per_request` (5), `user_quota_mib` (100), `rate_limit_per_15min` (30). `src/lib/uploads.ts` validates them at startup and refuses to boot on a bad value. Edit the YAML and re-deploy to change a limit. The oversize and quota checks run before each file is buffered.\\n- Type sniffing matches leading magic bytes only, so a crafted polyglot could carry a valid header. That is why serving forces `attachment` + `nosniff` and never renders inline; do not weaken that (see below). Re-encode images if you need stronger guarantees.\\n- On-disk name is `sha256(content).<sniffed-ext>`, so it can never contain a path separator or traversal segment, and identical bytes are stored once. The original filename is kept as display metadata only, sanitized.\\n- Bytes live under `<db-dir>/uploads` (derived from `DB_PATH`, so the container's `/data` volume); metadata is the `uploads` table.\\n- `resolveStored` rejects any name that is not a bare content-addressed name or that would escape the uploads directory; serving reads only through it.\\n\\n## Request size\\n\\nInstalling uploads raises `maxRequestBodySize` in `src/server.ts` to a fixed 55 MiB. That is the hard server ceiling and is generous headroom over the default limits; if you raise `max_file_mib` x `max_files_per_request` above ~55 MiB in the config, raise this server value to match or the server rejects the body first. Removal restores 1 MiB. Because that ceiling admits large bodies, the auth extension caps its own JSON routes by an actual-bytes read independently; keep that guard on any small-body route you add.\\n\\n## Serving untrusted files\\n\\nDownloads are forced (`attachment`) and owner-scoped by default. Do not switch to inline rendering for user-supplied files without a strict `Content-Security-Policy` and a separate origin; an inline HTML or SVG upload is stored XSS otherwise.\\n\\n## Removal\\n\\n`bun run shibumi remove uploads` deletes the code and reverses the edits (including the `maxRequestBodySize` bump). Remove `uploads` before removing `auth`. The `uploads` table and stored files are never touched by tooling:\\n\\n```sql\\nDROP TABLE uploads;\\n```\\nThen clear `<db-dir>/uploads` if you no longer need the files.\\n\",\"rootSection\":\"## Uploads extension\\n\\nInstalled by `bun run shibumi add uploads` (needs the auth extension); full guide in `agents/uploads.md`.\\n\\n- Routes under `/uploads` (POST upload, GET list, GET :id download, DELETE :id) live in `src/routes/uploads.ts`; validation, storage, and serving in `src/lib/uploads.ts`. Every route requires a session; CSRF covers mutations.\\n- File type is decided by sniffing magic bytes (png/jpeg/gif/webp/pdf), never the client filename or Content-Type. Limits in src/config/uploads.yaml (5 MiB/file, 5/request, 100 MiB/user, 30/15min by default), validated at startup.\\n- Bytes are stored content-addressed (sha256 + sniffed extension) under `<db-dir>/uploads` (the `/data` volume in the container); metadata is the `uploads` table in app.db. On-disk names can never contain a path separator.\\n- Serving is owner-scoped and forces `Content-Disposition: attachment`; uploads are never rendered inline.\\n- The server's `maxRequestBodySize` is raised to 55 MiB by this extension; removal restores 1 MiB.\\n- Removal deletes code and reverses edits; the `uploads` table and stored files stay. Drop/clear them manually.\",\"removeNote\":\"The uploads table stays in app.db and stored files stay under <db-dir>/uploads wherever they were written. Drop the table (DROP TABLE uploads;) and clear the directory manually if unwanted.\"}]";
57
+ const EXTENSIONS_JSON = "[{\"name\":\"admin\",\"title\":\"Admin\",\"description\":\"Minimal server-rendered admin panel: list and delete users, gated by an ADMIN_EMAILS allowlist\",\"version\":\"1.0.1\",\"requires\":\"database\",\"dependsOn\":[\"auth\"],\"env\":[\"ADMIN_EMAILS\"],\"files\":[{\"to\":\"public/admin.css\",\"content\":\"/* Admin panel styles. Reuses the paper/ink/persimmon tokens from style.css;\\n utilitarian, no framework. */\\n.admin {\\n max-width: 60rem;\\n}\\n\\n.admin .muted {\\n opacity: 0.65;\\n font-size: 0.9rem;\\n}\\n\\n.admin table {\\n width: 100%;\\n border-collapse: collapse;\\n margin-top: 1rem;\\n}\\n\\n.admin th,\\n.admin td {\\n text-align: left;\\n padding: 0.5rem 0.75rem;\\n border-bottom: 1px solid color-mix(in srgb, var(--ink) 15%, transparent);\\n vertical-align: middle;\\n}\\n\\n.admin th {\\n font-size: 0.8rem;\\n text-transform: uppercase;\\n letter-spacing: 0.04em;\\n opacity: 0.7;\\n}\\n\\n.admin form {\\n margin: 0;\\n}\\n\\n.admin button {\\n font: inherit;\\n cursor: pointer;\\n border: 1px solid color-mix(in srgb, var(--ink) 25%, transparent);\\n background: transparent;\\n color: var(--ink);\\n padding: 0.3rem 0.7rem;\\n border-radius: 0.3rem;\\n}\\n\\n.admin button.danger {\\n border-color: var(--accent);\\n color: var(--accent);\\n}\\n\\n.admin button.danger:hover {\\n background: var(--accent);\\n color: var(--paper);\\n}\\n\"},{\"to\":\"public/admin.js\",\"content\":\"// Admin panel behavior. Self-hosted so it runs under the app's\\n// script-src 'self' CSP. Confirms destructive form submits using the\\n// message in each form's data-confirm attribute (safe against apostrophes\\n// and markup, unlike an inline handler).\\ndocument.addEventListener(\\\"submit\\\", (event) => {\\n const form = event.target;\\n if (!(form instanceof HTMLFormElement)) return;\\n const message = form.dataset.confirm;\\n if (message && !window.confirm(message)) {\\n event.preventDefault();\\n }\\n});\\n\"},{\"to\":\"src/lib/admin.ts\",\"content\":\"// Admin authorization and read models. Installed by\\n// `bun run shibumi add admin` (needs the auth extension). This project owns\\n// the file. Admins are defined by the ADMIN_EMAILS allowlist, not a database\\n// flag, so there is no schema coupling to the auth tables and no bootstrap\\n// step: set the env var and that account is an admin.\\nimport { eq } from \\\"drizzle-orm\\\";\\nimport { db, sqlite } from \\\"../db\\\";\\nimport { users } from \\\"../db/schema-auth\\\";\\nimport { loadEnv } from \\\"../env\\\";\\nimport { normalizeEmail } from \\\"./auth\\\";\\n\\nexport function adminEmails(): Set<string> {\\n const raw = loadEnv().ADMIN_EMAILS ?? \\\"\\\";\\n return new Set(\\n raw\\n .split(\\\",\\\")\\n .map((entry) => normalizeEmail(entry))\\n .filter(Boolean)\\n );\\n}\\n\\nexport function isAdmin(email: string): boolean {\\n const allow = adminEmails();\\n return allow.size > 0 && allow.has(normalizeEmail(email));\\n}\\n\\n// Does a given table exist? Lets the admin read upload counts only when the\\n// uploads extension is installed, without importing its schema.\\nfunction tableExists(name: string): boolean {\\n const row = sqlite\\n .query<{ n: number }, [string]>(\\\"SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name=?\\\")\\n .get(name);\\n return (row?.n ?? 0) > 0;\\n}\\n\\nexport interface AdminUserRow {\\n id: number;\\n email: string;\\n createdAt: string;\\n sessions: number;\\n uploads: number | null;\\n}\\n\\nexport function listUsers(): AdminUserRow[] {\\n const rows = db.select().from(users).all();\\n const hasUploads = tableExists(\\\"uploads\\\");\\n const sessionCount = sqlite.query<{ n: number }, [number]>(\\n \\\"SELECT count(*) AS n FROM sessions WHERE user_id = ?\\\"\\n );\\n const uploadCount = hasUploads\\n ? sqlite.query<{ n: number }, [number]>(\\\"SELECT count(*) AS n FROM uploads WHERE user_id = ?\\\")\\n : null;\\n return rows.map((row) => ({\\n id: row.id,\\n email: row.email,\\n createdAt: row.createdAt,\\n sessions: sessionCount.get(row.id)?.n ?? 0,\\n uploads: uploadCount ? (uploadCount.get(row.id)?.n ?? 0) : null,\\n }));\\n}\\n\\n// Deleting a user cascades to sessions, login tokens, and uploads rows via the\\n// foreign keys those tables declare. Stored upload blobs are not swept here;\\n// the uploads extension owns that.\\nexport async function deleteUser(id: number): Promise<boolean> {\\n const removed = await db.delete(users).where(eq(users.id, id)).returning();\\n return removed.length > 0;\\n}\\n\"},{\"to\":\"src/routes/admin.ts\",\"content\":\"// Admin panel, mounted at /admin by the installer. Server-rendered HTML, no\\n// client JS: actions are plain form POSTs. Every route requires a session and\\n// an email on the ADMIN_EMAILS allowlist; CSRF covers the mutations.\\nimport { Hono } from \\\"hono\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { getCookie } from \\\"hono/cookie\\\";\\nimport { SESSION_COOKIE, csrfOptions, sessionUser, type AuthUser } from \\\"../lib/auth\\\";\\nimport { deleteUser, isAdmin, listUsers, type AdminUserRow } from \\\"../lib/admin\\\";\\n\\ntype AdminEnv = { Variables: { user: AuthUser } };\\n\\nexport const adminRoutes = new Hono<AdminEnv>();\\n\\nadminRoutes.use(csrf(csrfOptions()));\\n// Session + allowlist gate. Not `requireAuth`: admins get a 403 page, signed-out\\n// visitors a 401, both as HTML rather than JSON.\\nadminRoutes.use(async (c, next) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n if (!user) return c.html(page(\\\"Sign in required\\\", \\\"<p>Sign in to reach the admin panel.</p>\\\"), 401);\\n if (!isAdmin(user.email)) return c.html(page(\\\"Forbidden\\\", \\\"<p>This account is not an administrator.</p>\\\"), 403);\\n c.set(\\\"user\\\", user);\\n await next();\\n});\\n\\nfunction escapeHtml(value: string): string {\\n return value\\n .replaceAll(\\\"&\\\", \\\"&amp;\\\")\\n .replaceAll(\\\"<\\\", \\\"&lt;\\\")\\n .replaceAll(\\\">\\\", \\\"&gt;\\\")\\n .replaceAll('\\\"', \\\"&quot;\\\")\\n .replaceAll(\\\"'\\\", \\\"&#39;\\\");\\n}\\n\\nfunction page(title: string, body: string): string {\\n return `<!doctype html>\\n<html lang=\\\"en\\\">\\n <head>\\n <meta charset=\\\"utf-8\\\" />\\n <meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\" />\\n <title>${escapeHtml(title)}</title>\\n <link rel=\\\"stylesheet\\\" href=\\\"/public/style.css\\\" />\\n <link rel=\\\"stylesheet\\\" href=\\\"/public/admin.css\\\" />\\n <!-- Self-hosted so it runs under the app's script-src 'self' CSP; an\\n inline handler would be blocked. -->\\n <script src=\\\"/public/admin.js\\\" defer></script>\\n </head>\\n <body>\\n <main class=\\\"admin\\\">\\n <h1>${escapeHtml(title)}</h1>\\n ${body}\\n </main>\\n </body>\\n</html>\\n`;\\n}\\n\\nfunction usersTable(rows: AdminUserRow[], self: number): string {\\n if (rows.length === 0) return \\\"<p>No users yet.</p>\\\";\\n const showUploads = rows.some((row) => row.uploads !== null);\\n const head = `<tr><th>ID</th><th>Email</th><th>Created</th><th>Sessions</th>${\\n showUploads ? \\\"<th>Uploads</th>\\\" : \\\"\\\"\\n }<th></th></tr>`;\\n const body = rows\\n .map((row) => {\\n const uploads = showUploads ? `<td>${row.uploads ?? 0}</td>` : \\\"\\\";\\n const action =\\n row.id === self\\n ? `<td class=\\\"muted\\\">you</td>`\\n : `<td><form method=\\\"post\\\" action=\\\"/admin/users/${row.id}/delete\\\" data-confirm=\\\"Delete ${escapeHtml(\\n row.email\\n )}?\\\"><button type=\\\"submit\\\" class=\\\"danger\\\">Delete</button></form></td>`;\\n return `<tr><td>${row.id}</td><td>${escapeHtml(row.email)}</td><td>${escapeHtml(\\n row.createdAt\\n )}</td><td>${row.sessions}</td>${uploads}${action}</tr>`;\\n })\\n .join(\\\"\\\");\\n return `<table>${head}${body}</table>`;\\n}\\n\\nadminRoutes.get(\\\"/\\\", (c) => {\\n const rows = listUsers();\\n return c.html(\\n page(\\n \\\"Users\\\",\\n `<p class=\\\"muted\\\">${rows.length} account${rows.length === 1 ? \\\"\\\" : \\\"s\\\"}.</p>${usersTable(\\n rows,\\n c.get(\\\"user\\\").id\\n )}`\\n )\\n );\\n});\\n\\nadminRoutes.post(\\\"/users/:id/delete\\\", async (c) => {\\n const raw = c.req.param(\\\"id\\\") ?? \\\"\\\";\\n const id = /^\\\\d+$/.test(raw) ? Number(raw) : NaN;\\n if (!Number.isSafeInteger(id)) return c.html(page(\\\"Bad request\\\", \\\"<p>Invalid id.</p>\\\"), 400);\\n if (id === c.get(\\\"user\\\").id) {\\n return c.html(page(\\\"Not allowed\\\", \\\"<p>You cannot delete your own account here.</p>\\\"), 400);\\n }\\n await deleteUser(id);\\n return c.redirect(\\\"/admin\\\");\\n});\\n\"},{\"to\":\"test/admin.test.ts\",\"content\":\"import { beforeAll, describe, expect, it } from \\\"bun:test\\\";\\nimport { mkdtempSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\nprocess.env.DB_PATH = join(mkdtempSync(join(tmpdir(), \\\"admin-test-\\\")), \\\"app.db\\\");\\nprocess.env.ADMIN_EMAILS = \\\"boss@example.com, Owner@Example.com\\\";\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { createUser, createSession, SESSION_COOKIE } = await import(\\\"../src/lib/auth\\\");\\nconst { isAdmin, listUsers } = await import(\\\"../src/lib/admin\\\");\\nawait applyMigrations(sqlite);\\n\\nlet counter = 0;\\nasync function makeUser(email?: string): Promise<{ id: number; email: string; cookie: string }> {\\n counter += 1;\\n const addr = email ?? `member${counter}-${Date.now()}@example.com`;\\n const user = await createUser(addr, \\\"password123\\\");\\n const token = await createSession(user.id);\\n return { id: user.id, email: addr, cookie: `${SESSION_COOKIE}=${token}` };\\n}\\n\\nlet admin: { id: number; cookie: string };\\nbeforeAll(async () => {\\n admin = await makeUser(\\\"boss@example.com\\\");\\n});\\n\\ndescribe(\\\"isAdmin\\\", () => {\\n it(\\\"matches the allowlist case-insensitively and rejects others\\\", () => {\\n expect(isAdmin(\\\"boss@example.com\\\")).toBe(true);\\n expect(isAdmin(\\\"owner@example.com\\\")).toBe(true);\\n expect(isAdmin(\\\"OWNER@EXAMPLE.COM\\\")).toBe(true);\\n expect(isAdmin(\\\"nobody@example.com\\\")).toBe(false);\\n expect(isAdmin(\\\"\\\")).toBe(false);\\n });\\n});\\n\\ndescribe(\\\"access control\\\", () => {\\n it(\\\"401s when signed out\\\", async () => {\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\"));\\n expect(res.status).toBe(401);\\n });\\n\\n it(\\\"403s a signed-in non-admin\\\", async () => {\\n const member = await makeUser();\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\", { headers: { cookie: member.cookie } }));\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"renders the user table for an admin\\\", async () => {\\n const member = await makeUser(\\\"visible@example.com\\\");\\n const res = await app.fetch(new Request(\\\"http://localhost/admin\\\", { headers: { cookie: admin.cookie } }));\\n expect(res.status).toBe(200);\\n expect(res.headers.get(\\\"content-type\\\")).toContain(\\\"text/html\\\");\\n const html = await res.text();\\n expect(html).toContain(\\\"visible@example.com\\\");\\n expect(html).toContain(\\\"/admin/users/\\\");\\n void member;\\n });\\n});\\n\\ndescribe(\\\"delete user\\\", () => {\\n it(\\\"blocks cross-origin form posts (CSRF)\\\", async () => {\\n const target = await makeUser();\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"https://evil.example\\\", \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\" },\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"lets an admin delete another user and refuses self-delete\\\", async () => {\\n const target = await makeUser(\\\"doomed@example.com\\\");\\n const before = listUsers().length;\\n\\n const selfDelete = await app.fetch(\\n new Request(`http://localhost/admin/users/${admin.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(selfDelete.status).toBe(400);\\n\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: admin.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(res.status).toBe(302);\\n expect(listUsers().length).toBe(before - 1);\\n expect(listUsers().some((row) => row.email === \\\"doomed@example.com\\\")).toBe(false);\\n });\\n\\n it(\\\"a non-admin cannot delete\\\", async () => {\\n const attacker = await makeUser();\\n const target = await makeUser();\\n const res = await app.fetch(\\n new Request(`http://localhost/admin/users/${target.id}/delete`, {\\n method: \\\"POST\\\",\\n headers: { cookie: attacker.cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(res.status).toBe(403);\\n expect(listUsers().some((row) => row.id === target.id)).toBe(true);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { adminRoutes } from \\\"./routes/admin\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/admin\\\", adminRoutes);\"},{\"file\":\"src/env.ts\",\"find\":\" DB_PATH: z.string().min(1).default(\\\"data/app.db\\\"),\",\"insert\":\" // Admin extension (shibumi add admin): comma-separated admin emails.\\n ADMIN_EMAILS: z.string().optional(),\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Admin extension (bun run shibumi add admin): allowlist-gated and\\n // CSRF-protected; covered by test/admin.test.ts. Two ALL entries: the\\n // CSRF and admin-guard middlewares.\\n \\\"ALL /admin/*\\\",\\n \\\"ALL /admin/*\\\",\\n \\\"GET /admin\\\",\\n \\\"POST /admin/users/:id/delete\\\",\"}],\"migration\":null,\"agentsFile\":\"# Admin extension\\n\\nInstalled by `bun run shibumi add admin`. Needs the auth extension. This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/admin.ts`: the `ADMIN_EMAILS` allowlist check (`isAdmin`), the user read model (`listUsers`), and `deleteUser`.\\n- `src/routes/admin.ts`: server-rendered panel mounted at `/admin` (no client JS; actions are form POSTs).\\n- `public/admin.css`: small utilitarian styles reusing the template's paper/ink/persimmon tokens.\\n- `test/admin.test.ts`: access-control, CSRF, and delete coverage.\\n\\n## Who is an admin\\n\\nAdmins are the emails in the `ADMIN_EMAILS` environment variable (comma-separated, case-insensitive), validated in `src/env.ts`. There is no database flag: set the variable and that account is an admin. With `ADMIN_EMAILS` empty, no one is an admin and `/admin` is locked.\\n\\nBecause access is granted by email, the auth extension **reserves** every `ADMIN_EMAILS` address from self-service password registration (a `403` at `/auth/register`), so an attacker cannot register the admin address before you. Create the admin account by one of:\\n\\n- **Login link** (proves inbox control): request `/auth/login-link` for the admin email once the email extension is wired.\\n- **Console seed, local development**: `bun -e 'import { createUser } from \\\"./src/lib/auth\\\"; await createUser(\\\"you@example.com\\\", process.env.SEED_PW!)'` with `SEED_PW` set in the environment. This writes the local database only.\\n- **Console seed, deployed server**: the shipped container carries compiled `dist/` without `src/`, so the import above cannot run there. Seed the production database inside the container instead (same `Bun.password` default hash):\\n\\n ```\\n printf '%s' \\\"$SEED_PW\\\" | podman exec -i <container-name> bun -e '\\n const pw = await new Response(Bun.stdin.stream()).text();\\n const { Database } = await import(\\\"bun:sqlite\\\");\\n new Database(\\\"/data/app.db\\\").run(\\n \\\"INSERT INTO users (email, password_hash) VALUES (?, ?)\\\",\\n [\\\"you@example.com\\\", await Bun.password.hash(pw)]);'\\n ```\\n\\n The password arrives over stdin so it never appears in the host process list.\\n\\nSet `ADMIN_EMAILS` before the app is publicly reachable: `bun ship:env set ADMIN_EMAILS=you@example.com`, then `bun ship`.\\n\\n## Endpoints\\n\\n- `GET /admin` → HTML user table (id, email, created, session count, upload count when the uploads extension is installed).\\n- `POST /admin/users/:id/delete` → deletes a user (cascades to sessions, login tokens, and upload rows via their foreign keys); refuses self-deletion. CSRF protected.\\n\\nSigned-out visitors get a 401 page, signed-in non-admins a 403 page.\\n\\n## Notes\\n\\n- Deleting a user does not sweep stored upload blobs on disk; the uploads extension owns that lifecycle. Clear `<db-dir>/uploads` separately if needed.\\n- The panel is intentionally minimal. Add columns or actions in `src/routes/admin.ts`; keep every mutation a CSRF-protected POST and every route behind the allowlist gate.\\n\\n## Removal\\n\\n`bun run shibumi remove admin` deletes the code and reverses the edits. Remove `admin` before removing `auth`. Remove the `ADMIN_EMAILS` variable from the environment when no longer needed.\\n\",\"rootSection\":\"## Admin extension\\n\\nInstalled by `bun run shibumi add admin` (needs the auth extension); full guide in `agents/admin.md`.\\n\\n- Server-rendered panel at `/admin` (`src/routes/admin.ts`); authorization and read models in `src/lib/admin.ts`; styles in `public/admin.css`.\\n- Admins are the emails in `ADMIN_EMAILS` (comma-separated, case-insensitive), validated in `src/env.ts`. Empty means no admins and `/admin` is locked.\\n- `GET /admin` lists users; `POST /admin/users/:id/delete` deletes one (CSRF-protected, cascades via foreign keys, refuses self-delete). Signed-out visitors get 401, non-admins 403.\\n- No new tables. Deleting a user does not sweep upload blobs; the uploads extension owns that.\\n- Removal deletes code and reverses edits; remove admin before auth.\",\"removeNote\":\"Remove admin before auth (admin depends on it). Remove the ADMIN_EMAILS variable from the environment when no longer needed.\"},{\"name\":\"auth\",\"title\":\"Auth\",\"description\":\"Cookie sessions with password and login-link sign-in, CSRF protection, and rate limiting\",\"version\":\"1.0.1\",\"requires\":\"database\",\"env\":[\"APP_ORIGIN\"],\"files\":[{\"to\":\"src/config/auth.yaml\",\"content\":\"# Auth extension config. Bundled into the image at build time; edit and\\n# re-deploy (bun ship) to apply. Validated at startup by src/lib/auth.ts, which\\n# refuses to boot on a bad value.\\n\\n# How long a session cookie stays valid, in days.\\nsession_days: 7\\n\\n# How long a login link stays valid, in minutes.\\nlogin_link_minutes: 15\\n\\n# Minimum password length (the maximum is fixed at 128).\\npassword_min_length: 8\\n\\n# Per-IP rate limits, each counted per 15 minutes.\\nregister_rate_per_15min: 10\\nlogin_rate_per_15min: 10\\nlogin_link_rate_per_15min: 5\\n\"},{\"to\":\"src/db/schema-auth.ts\",\"content\":\"import { sql } from \\\"drizzle-orm\\\";\\nimport { integer, sqliteTable, text } from \\\"drizzle-orm/sqlite-core\\\";\\n\\n// Owned by the auth extension (bun run shibumi add auth). These tables live\\n// in the same app.db as the rest of the project, created by the installed\\n// migration in src/db/migrations/; keep schema and migration in sync.\\nexport const users = sqliteTable(\\\"users\\\", {\\n id: integer(\\\"id\\\").primaryKey({ autoIncrement: true }),\\n email: text(\\\"email\\\").notNull().unique(),\\n // Null for accounts that only ever log in via login link.\\n passwordHash: text(\\\"password_hash\\\"),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\\n// Sessions and login tokens store sha256 hashes of the tokens handed to the\\n// browser, never the tokens themselves; a leaked database cannot mint logins.\\nexport const sessions = sqliteTable(\\\"sessions\\\", {\\n tokenHash: text(\\\"token_hash\\\").primaryKey(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n expiresAt: text(\\\"expires_at\\\").notNull(),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\\nexport const loginTokens = sqliteTable(\\\"login_tokens\\\", {\\n tokenHash: text(\\\"token_hash\\\").primaryKey(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n expiresAt: text(\\\"expires_at\\\").notNull(),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\"},{\"to\":\"src/lib/auth.ts\",\"content\":\"// Auth core: users, hashed cookie sessions, single-use login tokens, and a\\n// fixed-window rate limiter. Installed by `bun run shibumi add auth`; this\\n// project owns the file. Full guide: agents/auth.md.\\n//\\n// Invariants:\\n// - Session and login tokens leave this module only as opaque random strings;\\n// the database stores sha256 hashes, so a leaked database cannot mint\\n// sessions or logins.\\n// - Login tokens are single-use (deleted on first consume, valid or not) and\\n// expire after 15 minutes.\\n// - Password checks always run Bun.password.verify, including for unknown\\n// emails, so response timing does not reveal whether an account exists.\\n// - The rate limiter is in-memory and per-process, which matches the\\n// single-container deployment; counts reset on restart.\\nimport type { Context, Next } from \\\"hono\\\";\\nimport { getCookie } from \\\"hono/cookie\\\";\\nimport { eq, lt } from \\\"drizzle-orm\\\";\\nimport { db } from \\\"../db\\\";\\nimport { loginTokens, sessions, users } from \\\"../db/schema-auth\\\";\\n// Editable knobs live in config/auth.yaml; Bun bundles the parsed values into\\n// the image at build time. Edit that file and re-deploy to change them.\\nimport rawAuthConfig from \\\"../config/auth.yaml\\\";\\n\\n// A bad edit throws here at module load, so the container fails its health\\n// check and the previous deployment stays live instead of running with a\\n// weakened limit.\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`auth config: ${key} must be a positive integer (config/auth.yaml)`);\\n }\\n return value;\\n}\\n\\nconst authConfig = (rawAuthConfig ?? {}) as Record<string, unknown>;\\n\\nconst SESSION_TTL_MS = positiveInt(authConfig, \\\"session_days\\\") * 24 * 60 * 60 * 1000;\\nconst LOGIN_TOKEN_TTL_MS = positiveInt(authConfig, \\\"login_link_minutes\\\") * 60 * 1000;\\n\\n// Rate limits (per IP, per 15-minute window) and the password floor are read\\n// by the routes; secondary per-email buckets and internal caps stay fixed.\\nexport const RATE_WINDOW_MS = 15 * 60 * 1000;\\nexport const PASSWORD_MIN_LENGTH = positiveInt(authConfig, \\\"password_min_length\\\");\\nexport const REGISTER_RATE = positiveInt(authConfig, \\\"register_rate_per_15min\\\");\\nexport const LOGIN_RATE = positiveInt(authConfig, \\\"login_rate_per_15min\\\");\\nexport const LOGIN_LINK_RATE = positiveInt(authConfig, \\\"login_link_rate_per_15min\\\");\\n\\nexport const SESSION_COOKIE = \\\"session\\\";\\nexport const SESSION_MAX_AGE_S = SESSION_TTL_MS / 1000;\\n\\nexport interface AuthUser {\\n id: number;\\n email: string;\\n createdAt: string;\\n}\\n\\ntype UserRow = typeof users.$inferSelect;\\n\\nfunction toAuthUser(row: UserRow): AuthUser {\\n return { id: row.id, email: row.email, createdAt: row.createdAt };\\n}\\n\\nexport function normalizeEmail(email: string): string {\\n return email.trim().toLowerCase();\\n}\\n\\nfunction newToken(): string {\\n const bytes = new Uint8Array(32);\\n crypto.getRandomValues(bytes);\\n return Buffer.from(bytes).toString(\\\"base64url\\\");\\n}\\n\\nexport function hashToken(token: string): string {\\n return new Bun.CryptoHasher(\\\"sha256\\\").update(token).digest(\\\"hex\\\");\\n}\\n\\n// Emails listed in ADMIN_EMAILS are privileged (the admin extension grants\\n// them access by address). They must not be claimable through self-service\\n// password registration, or an attacker could register the admin address\\n// before the operator. Reserved addresses can still sign in via the login\\n// link, which proves control of the inbox. No-op when ADMIN_EMAILS is unset\\n// (e.g. the admin extension is not installed).\\nexport function isReservedEmail(email: string): boolean {\\n const raw = process.env[\\\"ADMIN_EMAILS\\\"];\\n if (!raw) return false;\\n const target = normalizeEmail(email);\\n return raw\\n .split(\\\",\\\")\\n .map((entry) => normalizeEmail(entry))\\n .filter(Boolean)\\n .includes(target);\\n}\\n\\nfunction nowIso(): string {\\n return new Date().toISOString();\\n}\\n\\n// Users ----------------------------------------------------------------------\\n\\n// Throws on duplicate email (UNIQUE constraint); routes map that to 409.\\nexport async function createUser(email: string, password: string | null): Promise<AuthUser> {\\n const passwordHash = password === null ? null : await Bun.password.hash(password);\\n const rows = await db\\n .insert(users)\\n .values({ email: normalizeEmail(email), passwordHash })\\n .returning();\\n return toAuthUser(rows[0]!);\\n}\\n\\nexport async function getUserByEmail(email: string): Promise<UserRow | null> {\\n const rows = await db.select().from(users).where(eq(users.email, normalizeEmail(email)));\\n return rows[0] ?? null;\\n}\\n\\nexport async function getUserById(id: number): Promise<AuthUser | null> {\\n const rows = await db.select().from(users).where(eq(users.id, id));\\n return rows[0] ? toAuthUser(rows[0]) : null;\\n}\\n\\n// Verifying against this hash when the account is missing (or has no\\n// password) keeps timing uniform without ever granting access: the guard\\n// below requires the account's own stored hash to have matched.\\nlet dummyHashPromise: Promise<string> | undefined;\\nfunction dummyHash(): Promise<string> {\\n dummyHashPromise ??= Bun.password.hash(crypto.randomUUID());\\n return dummyHashPromise;\\n}\\n// Warm it at import so the first unknown-email login is not measurably\\n// slower than a known-email one.\\nvoid dummyHash();\\n\\nexport async function verifyLogin(email: string, password: string): Promise<AuthUser | null> {\\n const row = await getUserByEmail(email);\\n const ok = await Bun.password.verify(password, row?.passwordHash ?? (await dummyHash()));\\n if (!ok || !row?.passwordHash) return null;\\n return toAuthUser(row);\\n}\\n\\n// Sessions -------------------------------------------------------------------\\n\\nexport async function createSession(userId: number): Promise<string> {\\n // Login is the write path, so piggyback expired-row cleanup here and keep\\n // per-request session reads to a single lookup.\\n await db.delete(sessions).where(lt(sessions.expiresAt, nowIso()));\\n const token = newToken();\\n await db.insert(sessions).values({\\n tokenHash: hashToken(token),\\n userId,\\n expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),\\n });\\n return token;\\n}\\n\\nexport async function sessionUser(token: string): Promise<AuthUser | null> {\\n const tokenHash = hashToken(token);\\n const rows = await db\\n .select({ user: users, expiresAt: sessions.expiresAt })\\n .from(sessions)\\n .innerJoin(users, eq(sessions.userId, users.id))\\n .where(eq(sessions.tokenHash, tokenHash));\\n const row = rows[0];\\n if (!row) return null;\\n if (row.expiresAt <= nowIso()) {\\n await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));\\n return null;\\n }\\n return toAuthUser(row.user);\\n}\\n\\nexport async function destroySession(token: string): Promise<void> {\\n await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));\\n}\\n\\n// Login tokens (login-link flow) ----------------------------------------------\\n\\nexport async function createLoginToken(email: string): Promise<string | null> {\\n const row = await getUserByEmail(email);\\n if (!row) return null;\\n await db.delete(loginTokens).where(lt(loginTokens.expiresAt, nowIso()));\\n const token = newToken();\\n await db.insert(loginTokens).values({\\n tokenHash: hashToken(token),\\n userId: row.id,\\n expiresAt: new Date(Date.now() + LOGIN_TOKEN_TTL_MS).toISOString(),\\n });\\n return token;\\n}\\n\\nexport async function consumeLoginToken(token: string): Promise<AuthUser | null> {\\n // Delete first: the token is spent by the attempt, even an expired one.\\n const rows = await db\\n .delete(loginTokens)\\n .where(eq(loginTokens.tokenHash, hashToken(token)))\\n .returning();\\n const row = rows[0];\\n if (!row || row.expiresAt <= nowIso()) return null;\\n return getUserById(row.userId);\\n}\\n\\n// Delivery seam: the auth extension does not send email itself. With the\\n// email extension installed (bun run shibumi add email), replace the body\\n// with a sendEmail call; agents/auth.md has the exact snippet. Until then,\\n// development logs the link and production refuses instead of silently\\n// swallowing logins.\\n// Bracket access, not process.env.NODE_ENV: `bun build` statically inlines\\n// the dot form at build time (defaulting to \\\"development\\\"), which would defeat\\n// the runtime production check baked into the container. Bracket access is\\n// read at runtime.\\nexport function nodeEnv(): string | undefined {\\n return process.env[\\\"NODE_ENV\\\"];\\n}\\n\\n// Behind a TLS-terminating proxy (Caddy) the app sees its own URL as http://,\\n// so hono's default CSRF check (Origin header vs request URL origin) would\\n// reject every browser form POST in production: the browser sends the https\\n// origin, the request URL yields the http one. Pin the expected origin to\\n// APP_ORIGIN when set; without it (development) the same-origin default stays.\\nexport function csrfOptions(): { origin?: string } {\\n const appOrigin = process.env[\\\"APP_ORIGIN\\\"];\\n if (!appOrigin) {\\n // Unset in production would fall back to the same-origin default and 403\\n // every form POST behind the proxy. Fail loud at boot instead.\\n if (nodeEnv() === \\\"production\\\") {\\n throw new Error(\\\"APP_ORIGIN must be set in production (bun ship:env set APP_ORIGIN=https://your-domain, then bun ship).\\\");\\n }\\n return {};\\n }\\n const url = new URL(appOrigin);\\n // A non-http(s) URL serializes to origin \\\"null\\\", which an attacker can\\n // match from a sandboxed iframe (Origin: null). Refuse it.\\n if (url.protocol !== \\\"https:\\\" && url.protocol !== \\\"http:\\\") {\\n throw new Error(\\\"APP_ORIGIN must be an http(s) URL.\\\");\\n }\\n return { origin: url.origin };\\n}\\n\\nexport async function deliverLoginLink(email: string, url: string): Promise<void> {\\n // Fail-closed: only explicit development logs the link; any other value\\n // (including unset) refuses rather than printing tokens to output.\\n if (nodeEnv() !== \\\"development\\\") {\\n throw new Error(\\n \\\"Login-link delivery is not wired. Install the email extension (bun run shibumi add email) and connect it in src/lib/auth.ts (see agents/auth.md).\\\"\\n );\\n }\\n console.log(`Login link for ${email}: ${url}`);\\n}\\n\\n// Rate limiting ---------------------------------------------------------------\\n\\ninterface RateWindow {\\n start: number;\\n count: number;\\n}\\n\\nconst rateWindows = new Map<string, RateWindow>();\\n// Hard bound on tracked windows so attacker-minted keys (fresh IPs or\\n// emails) cannot grow memory without limit. At the cap, expired windows are\\n// pruned and, if every window is still live, the oldest are evicted; an\\n// attacker filling the map can reset other buckets, but the memory bound\\n// wins over that marginal rate-limit weakening.\\nconst RATE_MAP_CAP = 10_000;\\n\\n// Returns true while the caller stays within `limit` hits per `windowMs`.\\nexport function rateLimit(key: string, limit: number, windowMs: number, now = Date.now()): boolean {\\n const window = rateWindows.get(key);\\n if (window && now - window.start < windowMs) {\\n window.count += 1;\\n return window.count <= limit;\\n }\\n if (!window && rateWindows.size >= RATE_MAP_CAP) {\\n for (const [staleKey, stale] of rateWindows) {\\n if (now - stale.start >= windowMs) rateWindows.delete(staleKey);\\n }\\n while (rateWindows.size >= RATE_MAP_CAP) {\\n const oldest = rateWindows.keys().next().value;\\n if (oldest === undefined) break;\\n rateWindows.delete(oldest);\\n }\\n }\\n rateWindows.set(key, { start: now, count: 1 });\\n return true;\\n}\\n\\n// Middleware -------------------------------------------------------------------\\n\\nexport type AuthEnv = { Variables: { user: AuthUser } };\\n\\nexport async function requireAuth(c: Context, next: Next): Promise<Response | void> {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n if (!user) return c.json({ error: \\\"Authentication required\\\" }, 401);\\n c.set(\\\"user\\\", user);\\n await next();\\n}\\n\\nexport async function optionalAuth(c: Context, next: Next): Promise<void> {\\n const token = getCookie(c, SESSION_COOKIE);\\n if (token) {\\n const user = await sessionUser(token);\\n if (user) c.set(\\\"user\\\", user);\\n }\\n await next();\\n}\\n\"},{\"to\":\"src/routes/auth.ts\",\"content\":\"// Auth routes, mounted at /auth by the installer. JSON bodies in, JSON out.\\n// CSRF middleware (Origin check) covers every mutation; cross-origin JSON\\n// posts are additionally blocked by the browser preflight. Rate limits key on\\n// the client IP; see clientKey for how x-forwarded-for is trusted.\\nimport { Hono } from \\\"hono\\\";\\nimport type { Context } from \\\"hono\\\";\\nimport { getConnInfo } from \\\"hono/bun\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { deleteCookie, getCookie, setCookie } from \\\"hono/cookie\\\";\\nimport { z } from \\\"zod\\\";\\nimport { loadEnv } from \\\"../env\\\";\\nimport {\\n LOGIN_LINK_RATE,\\n LOGIN_RATE,\\n PASSWORD_MIN_LENGTH,\\n RATE_WINDOW_MS,\\n REGISTER_RATE,\\n SESSION_COOKIE,\\n SESSION_MAX_AGE_S,\\n consumeLoginToken,\\n createLoginToken,\\n createSession,\\n createUser,\\n csrfOptions,\\n deliverLoginLink,\\n destroySession,\\n isReservedEmail,\\n nodeEnv,\\n normalizeEmail,\\n rateLimit,\\n sessionUser,\\n verifyLogin,\\n} from \\\"../lib/auth\\\";\\n\\nexport const authRoutes = new Hono();\\n\\nauthRoutes.use(csrf(csrfOptions()));\\n\\n// `website` is a honeypot: a decoy field no real client sends. Bots that\\n// autofill it get a plausible response with no work done.\\nconst credentials = z.object({\\n email: z.email().max(254),\\n password: z.string().min(PASSWORD_MIN_LENGTH).max(128),\\n website: z.string().optional(),\\n});\\n\\nconst emailOnly = z.object({\\n email: z.email().max(254),\\n website: z.string().optional(),\\n});\\n\\nfunction honeypotTripped(body: unknown): boolean {\\n return (\\n typeof body === \\\"object\\\" &&\\n body !== null &&\\n \\\"website\\\" in body &&\\n typeof (body as { website: unknown }).website === \\\"string\\\" &&\\n (body as { website: string }).website.length > 0\\n );\\n}\\n\\nfunction fakeRegisterResponse(c: Context, email: string): Response {\\n // Shaped like the real 201, decoy session cookie included, so the bot\\n // learns nothing; no account or session exists.\\n const decoy = new Uint8Array(32);\\n crypto.getRandomValues(decoy);\\n setSessionCookie(c, Buffer.from(decoy).toString(\\\"base64url\\\"));\\n return c.json(\\n {\\n user: {\\n id: 1 + Math.floor(Math.random() * 1000),\\n email,\\n createdAt: new Date().toISOString(),\\n },\\n },\\n 201\\n );\\n}\\n\\nfunction isPrivateAddress(addr: string): boolean {\\n const ip = addr.replace(/^::ffff:/, \\\"\\\");\\n return (\\n ip === \\\"127.0.0.1\\\" ||\\n ip === \\\"::1\\\" ||\\n ip.startsWith(\\\"10.\\\") ||\\n ip.startsWith(\\\"192.168.\\\") ||\\n /^172\\\\.(1[6-9]|2\\\\d|3[01])\\\\./.test(ip) ||\\n ip.startsWith(\\\"fd\\\") ||\\n ip.startsWith(\\\"fc\\\")\\n );\\n}\\n\\n// Rate-limit key. x-forwarded-for is trusted only when the direct socket peer\\n// is a local reverse proxy (the shibumi-server / Caddy deployment) or when\\n// there is no socket peer at all (non-served test context). A directly\\n// reachable production app has a public peer and keys on it, so a spoofed\\n// x-forwarded-for cannot rotate rate-limit buckets.\\nfunction clientKey(c: Context): string {\\n let peer = \\\"\\\";\\n try {\\n peer = getConnInfo(c).remote.address ?? \\\"\\\";\\n } catch {\\n peer = \\\"\\\";\\n }\\n // A reverse proxy APPENDS the client it saw to x-forwarded-for, so the last\\n // entry is the one the trusted proxy added; the leftmost is attacker-set and\\n // must never be used as a key. Trust the header only when the direct peer is\\n // a local proxy (or, in tests, there is no socket peer).\\n if (!peer || isPrivateAddress(peer)) {\\n const entries = c.req.header(\\\"x-forwarded-for\\\")?.split(\\\",\\\").map((part) => part.trim()).filter(Boolean);\\n const last = entries?.at(-1);\\n if (last) return last;\\n }\\n return peer || \\\"local\\\";\\n}\\n\\nfunction tooMany(c: Context): Response {\\n return c.json({ error: \\\"Too many attempts. Try again later.\\\" }, 429);\\n}\\n\\n// Auth bodies are tiny (email + password). Reject anything larger before\\n// buffering, so an app that raised the server's global maxRequestBodySize for\\n// another feature (e.g. uploads) does not expose these routes to oversized\\n// JSON. Content-Length covers the common case; the server's global limit\\n// remains the hard ceiling for chunked requests.\\nconst MAX_AUTH_BODY_BYTES = 4 * 1024;\\nasync function jsonBody(c: Context): Promise<unknown> {\\n // Cap the actual bytes read, not just the declared Content-Length, so a\\n // chunked or length-spoofed request cannot buffer up to the server's global\\n // ceiling (raised by e.g. the uploads extension). Auth bodies are tiny.\\n const declared = Number(c.req.header(\\\"content-length\\\") ?? \\\"\\\");\\n if (Number.isFinite(declared) && declared > MAX_AUTH_BODY_BYTES) return null;\\n const body = c.req.raw.body;\\n if (!body) {\\n try {\\n return await c.req.json();\\n } catch {\\n return null;\\n }\\n }\\n const reader = body.getReader();\\n const chunks: Uint8Array[] = [];\\n let total = 0;\\n try {\\n for (;;) {\\n const { done, value } = await reader.read();\\n if (done) break;\\n total += value.length;\\n if (total > MAX_AUTH_BODY_BYTES) {\\n await reader.cancel();\\n return null;\\n }\\n chunks.push(value);\\n }\\n } catch {\\n return null;\\n }\\n try {\\n return JSON.parse(new TextDecoder().decode(Buffer.concat(chunks)));\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction setSessionCookie(c: Context, token: string): void {\\n // A token-bearing response must never be cached by a shared proxy.\\n c.header(\\\"Cache-Control\\\", \\\"no-store\\\");\\n // Secure works in local development too: browsers treat localhost as a\\n // secure context.\\n setCookie(c, SESSION_COOKIE, token, {\\n path: \\\"/\\\",\\n httpOnly: true,\\n secure: true,\\n sameSite: \\\"Lax\\\",\\n maxAge: SESSION_MAX_AGE_S,\\n });\\n}\\n\\nauthRoutes.post(\\\"/register\\\", async (c) => {\\n if (!rateLimit(`auth:register:${clientKey(c)}`, REGISTER_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const body = await jsonBody(c);\\n const parsed = credentials.safeParse(body);\\n if (honeypotTripped(body)) {\\n return fakeRegisterResponse(c, parsed.success ? normalizeEmail(parsed.data.email) : \\\"user@example.com\\\");\\n }\\n if (!parsed.success) {\\n return c.json({ error: `Provide a valid email and a password of ${PASSWORD_MIN_LENGTH} to 128 characters.` }, 400);\\n }\\n if (isReservedEmail(parsed.data.email)) {\\n // Privileged address: cannot be self-registered; must sign in via the\\n // login link (inbox proof) or be seeded by the operator.\\n return c.json({ error: \\\"This address is reserved. Sign in with a login link.\\\" }, 403);\\n }\\n try {\\n const user = await createUser(parsed.data.email, parsed.data.password);\\n setSessionCookie(c, await createSession(user.id));\\n return c.json({ user }, 201);\\n } catch (error) {\\n // Only the duplicate-email case maps to 409; anything else (hashing,\\n // database, session failure) surfaces as the generic 500.\\n if (error instanceof Error && error.message.includes(\\\"UNIQUE constraint failed\\\")) {\\n return c.json({ error: \\\"Email is already registered.\\\" }, 409);\\n }\\n throw error;\\n }\\n});\\n\\nauthRoutes.post(\\\"/login\\\", async (c) => {\\n const body = await jsonBody(c);\\n const parsed = credentials.safeParse(body);\\n // Invalid shapes still consume rate budget keyed by IP alone.\\n const email = parsed.success ? normalizeEmail(parsed.data.email) : \\\"\\\";\\n if (!rateLimit(`auth:login:${clientKey(c)}:${email}`, LOGIN_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n // IP-independent per-account bucket: credential stuffing from many IPs\\n // still hits a ceiling.\\n if (email && !rateLimit(`auth:login:email:${email}`, LOGIN_RATE * 5, RATE_WINDOW_MS)) return tooMany(c);\\n // Honeypot: answer exactly like a failed login, skip the work.\\n const user =\\n parsed.success && !honeypotTripped(body)\\n ? await verifyLogin(parsed.data.email, parsed.data.password)\\n : null;\\n if (!user) return c.json({ error: \\\"Invalid email or password.\\\" }, 401);\\n setSessionCookie(c, await createSession(user.id));\\n return c.json({ user });\\n});\\n\\nauthRoutes.post(\\\"/login-link\\\", async (c) => {\\n if (!rateLimit(`auth:link:${clientKey(c)}`, LOGIN_LINK_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const body = await jsonBody(c);\\n const parsed = emailOnly.safeParse(body);\\n if (!parsed.success) return c.json({ error: \\\"Provide a valid email.\\\" }, 400);\\n // Honeypot: the uniform response below already reveals nothing, so just\\n // skip token creation and delivery.\\n // Per-email bucket, uniform response when exceeded: rotating IPs must not\\n // turn login links into email bombing or a stack of live tokens.\\n const emailAllowed = rateLimit(`auth:link:email:${normalizeEmail(parsed.data.email)}`, LOGIN_LINK_RATE, RATE_WINDOW_MS);\\n const token =\\n honeypotTripped(body) || !emailAllowed ? null : await createLoginToken(parsed.data.email);\\n if (token) {\\n // Links are built from APP_ORIGIN, never the Host header, so a poisoned\\n // Host cannot redirect tokens. Fail-closed: the request-origin fallback\\n // is used only when NODE_ENV is explicitly \\\"development\\\"; any other value\\n // (including unset) requires APP_ORIGIN, and it must be https so tokens\\n // never ride plaintext.\\n const env = loadEnv();\\n const isDevelopment = nodeEnv() === \\\"development\\\";\\n const base = env.APP_ORIGIN ?? (isDevelopment ? new URL(c.req.url).origin : null);\\n try {\\n if (!base) {\\n throw new Error(\\\"APP_ORIGIN is not set; refusing to build login links from the Host header. Set APP_ORIGIN (https://...).\\\");\\n }\\n if (!isDevelopment && !base.startsWith(\\\"https://\\\")) {\\n throw new Error(`APP_ORIGIN must be https in production, got ${base}.`);\\n }\\n const url = new URL(`/auth/verify?token=${token}`, base).toString();\\n await deliverLoginLink(normalizeEmail(parsed.data.email), url);\\n } catch (error) {\\n // Delivery failure must not change the response, or it would reveal\\n // which emails have accounts.\\n console.error(error instanceof Error ? error.message : String(error));\\n }\\n }\\n return c.json({ ok: true, message: \\\"If that email is registered, a login link is on its way.\\\" });\\n});\\n\\nauthRoutes.get(\\\"/verify\\\", async (c) => {\\n if (!rateLimit(`auth:verify:${clientKey(c)}`, LOGIN_RATE, RATE_WINDOW_MS)) return tooMany(c);\\n const token = c.req.query(\\\"token\\\") ?? \\\"\\\";\\n const user = token ? await consumeLoginToken(token) : null;\\n if (!user) {\\n return c.json({ error: \\\"This login link is invalid or has expired. Request a new one.\\\" }, 400);\\n }\\n setSessionCookie(c, await createSession(user.id));\\n return c.redirect(\\\"/\\\");\\n});\\n\\nauthRoutes.post(\\\"/logout\\\", async (c) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n if (token) {\\n await destroySession(token);\\n deleteCookie(c, SESSION_COOKIE, { path: \\\"/\\\" });\\n }\\n return c.json({ ok: true });\\n});\\n\\nauthRoutes.get(\\\"/me\\\", async (c) => {\\n const token = getCookie(c, SESSION_COOKIE);\\n const user = token ? await sessionUser(token) : null;\\n return c.json({ user });\\n});\\n\"},{\"to\":\"test/auth.test.ts\",\"content\":\"import { describe, expect, it } from \\\"bun:test\\\";\\nimport { mkdtempSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\n// The db module opens DB_PATH at import time, so point it at a scratch\\n// database before the app loads. When another test file loaded first, its\\n// scratch path already won; migrations below are idempotent either way.\\nprocess.env.DB_PATH = join(mkdtempSync(join(tmpdir(), \\\"auth-test-\\\")), \\\"app.db\\\");\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { db, sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { sessions } = await import(\\\"../src/db/schema-auth\\\");\\nconst {\\n consumeLoginToken,\\n createLoginToken,\\n deliverLoginLink,\\n createSession,\\n createUser,\\n hashToken,\\n rateLimit,\\n sessionUser,\\n} = await import(\\\"../src/lib/auth\\\");\\nconst { eq } = await import(\\\"drizzle-orm\\\");\\nawait applyMigrations(sqlite);\\n\\n// Deliberate fail-closed paths (unset APP_ORIGIN, unwired link delivery) log\\n// through console.error while the HTTP response stays neutral. Filter exactly\\n// that expected noise so a green suite reads green; anything else still prints.\\nconst realConsoleLog = console.log;\\nconsole.log = (...args: unknown[]) => {\\n if (String(args[0] ?? \\\"\\\").startsWith(\\\"Login link for \\\")) return;\\n realConsoleLog(...args);\\n};\\nconst realConsoleError = console.error;\\nconsole.error = (...args: unknown[]) => {\\n const text = args.map(String).join(\\\" \\\");\\n if (text.includes(\\\"APP_ORIGIN is not set\\\") || text.includes(\\\"Login-link delivery is not wired\\\")) return;\\n realConsoleError(...args);\\n};\\n\\nlet userCounter = 0;\\nfunction uniqueEmail(): string {\\n userCounter += 1;\\n return `user${userCounter}-${Date.now()}@example.com`;\\n}\\n\\nlet ipCounter = 0;\\nfunction uniqueIp(): string {\\n ipCounter += 1;\\n return `10.1.${Math.floor(ipCounter / 250)}.${(ipCounter % 250) + 1}`;\\n}\\n\\nasync function post(\\n path: string,\\n body: unknown,\\n options: { ip?: string; headers?: Record<string, string> } = {}\\n): Promise<Response> {\\n return app.fetch(\\n new Request(`http://localhost${path}`, {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/json\\\",\\n \\\"x-forwarded-for\\\": options.ip ?? uniqueIp(),\\n ...(options.headers ?? {}),\\n },\\n body: JSON.stringify(body),\\n })\\n );\\n}\\n\\nfunction sessionTokenFrom(res: Response): string {\\n const cookie = res.headers.get(\\\"set-cookie\\\") ?? \\\"\\\";\\n const match = cookie.match(/session=([^;]+)/);\\n expect(match).not.toBeNull();\\n return match![1]!;\\n}\\n\\ndescribe(\\\"register and login\\\", () => {\\n it(\\\"registers, sets a hardened session cookie, and stores only the token hash\\\", async () => {\\n const email = uniqueEmail();\\n const res = await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n expect(res.status).toBe(201);\\n const cookie = res.headers.get(\\\"set-cookie\\\") ?? \\\"\\\";\\n expect(cookie).toContain(\\\"HttpOnly\\\");\\n expect(cookie).toContain(\\\"Secure\\\");\\n expect(cookie).toContain(\\\"SameSite=Lax\\\");\\n expect(cookie).toContain(\\\"Path=/\\\");\\n\\n const token = sessionTokenFrom(res);\\n const rows = await db.select().from(sessions).where(eq(sessions.tokenHash, hashToken(token)));\\n expect(rows.length).toBe(1);\\n expect(rows[0]!.tokenHash).not.toBe(token);\\n });\\n\\n it(\\\"reserves ADMIN_EMAILS addresses from self-service registration\\\", async () => {\\n const reserved = uniqueEmail();\\n const original = process.env.ADMIN_EMAILS;\\n process.env.ADMIN_EMAILS = `${reserved}, someone-else@example.com`;\\n try {\\n const res = await post(\\\"/auth/register\\\", { email: reserved, password: \\\"password123\\\" });\\n expect(res.status).toBe(403);\\n expect(((await res.json()) as { error: string }).error).toContain(\\\"reserved\\\");\\n // A non-reserved address still registers.\\n expect((await post(\\\"/auth/register\\\", { email: uniqueEmail(), password: \\\"password123\\\" })).status).toBe(201);\\n // Reserved address can still get a login link (inbox proof).\\n const link = await post(\\\"/auth/login-link\\\", { email: reserved });\\n expect(link.status).toBe(200);\\n } finally {\\n if (original === undefined) delete process.env.ADMIN_EMAILS;\\n else process.env.ADMIN_EMAILS = original;\\n }\\n });\\n\\n it(\\\"rejects duplicate registration with 409\\\", async () => {\\n const email = uniqueEmail();\\n expect((await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" })).status).toBe(201);\\n expect((await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" })).status).toBe(409);\\n });\\n\\n it(\\\"rejects invalid registration input\\\", async () => {\\n expect((await post(\\\"/auth/register\\\", { email: \\\"not-an-email\\\", password: \\\"password123\\\" })).status).toBe(400);\\n expect((await post(\\\"/auth/register\\\", { email: uniqueEmail(), password: \\\"short\\\" })).status).toBe(400);\\n });\\n\\n it(\\\"logs in with correct credentials and returns one uniform error otherwise\\\", async () => {\\n const email = uniqueEmail();\\n await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n\\n const ok = await post(\\\"/auth/login\\\", { email, password: \\\"password123\\\" });\\n expect(ok.status).toBe(200);\\n\\n const wrongPassword = await post(\\\"/auth/login\\\", { email, password: \\\"wrong-password\\\" });\\n const unknownEmail = await post(\\\"/auth/login\\\", { email: uniqueEmail(), password: \\\"password123\\\" });\\n expect(wrongPassword.status).toBe(401);\\n expect(unknownEmail.status).toBe(401);\\n expect(await wrongPassword.json()).toEqual(await unknownEmail.json());\\n });\\n\\n it(\\\"honeypot submissions get plausible responses but create nothing\\\", async () => {\\n const email = uniqueEmail();\\n const trapped = await post(\\\"/auth/register\\\", {\\n email,\\n password: \\\"password123\\\",\\n website: \\\"https://spam.example\\\",\\n });\\n expect(trapped.status).toBe(201);\\n // A decoy cookie is set so the response is indistinguishable, but it\\n // maps to no session.\\n const decoy = (trapped.headers.get(\\\"set-cookie\\\") ?? \\\"\\\").match(/session=([^;]+)/)![1]!;\\n const decoyMe = await app.fetch(\\n new Request(\\\"http://localhost/auth/me\\\", { headers: { cookie: `session=${decoy}` } })\\n );\\n expect(((await decoyMe.json()) as { user: null }).user).toBeNull();\\n\\n // No account was created, so a real login with those credentials fails.\\n const login = await post(\\\"/auth/login\\\", { email, password: \\\"password123\\\" });\\n expect(login.status).toBe(401);\\n\\n const trappedLogin = await post(\\\"/auth/login\\\", {\\n email,\\n password: \\\"password123\\\",\\n website: \\\"x\\\",\\n });\\n expect(trappedLogin.status).toBe(401);\\n\\n const trappedLink = await post(\\\"/auth/login-link\\\", { email, website: \\\"x\\\" });\\n const realLink = await post(\\\"/auth/login-link\\\", { email: uniqueEmail() });\\n expect(trappedLink.status).toBe(200);\\n expect(await trappedLink.json()).toEqual(await realLink.json());\\n\\n // An empty honeypot field is what real clients send; it must not trip.\\n const clean = await post(\\\"/auth/register\\\", {\\n email: uniqueEmail(),\\n password: \\\"password123\\\",\\n website: \\\"\\\",\\n });\\n expect(clean.status).toBe(201);\\n expect(clean.headers.get(\\\"set-cookie\\\")).not.toBeNull();\\n });\\n\\n it(\\\"rate limits login attempts per IP and email\\\", async () => {\\n const email = uniqueEmail();\\n const ip = uniqueIp();\\n let limited = false;\\n for (let i = 0; i < 11; i++) {\\n const res = await post(\\\"/auth/login\\\", { email, password: \\\"wrong-password\\\" }, { ip });\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n\\n it(\\\"caps an oversized request body (treated as an invalid body)\\\", async () => {\\n // The 4 KiB cap makes jsonBody return null; register reports that as 400.\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/register\\\", {\\n method: \\\"POST\\\",\\n headers: { \\\"content-type\\\": \\\"application/json\\\", \\\"x-forwarded-for\\\": uniqueIp() },\\n body: JSON.stringify({ email: \\\"big@example.com\\\", password: \\\"a\\\".repeat(20 * 1024) }),\\n })\\n );\\n expect(res.status).toBe(400);\\n });\\n\\n it(\\\"keys the rate limit on the proxy-appended (rightmost) forwarded IP\\\", async () => {\\n // The trusted proxy appends the real client; a spoofed leftmost entry must\\n // not create fresh buckets. Fixed rightmost, varying leftmost -> one bucket.\\n const realIp = uniqueIp();\\n let limited = false;\\n for (let i = 0; i < 7; i++) {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/login-link\\\", {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/json\\\",\\n \\\"x-forwarded-for\\\": `203.0.113.${i}, ${realIp}`,\\n },\\n body: JSON.stringify({ email: `r${i}@example.com` }),\\n })\\n );\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"sessions\\\", () => {\\n it(\\\"reports the user on /auth/me and clears the session on logout\\\", async () => {\\n const email = uniqueEmail();\\n const res = await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n const token = sessionTokenFrom(res);\\n const withCookie = { cookie: `session=${token}` };\\n\\n const me = await app.fetch(new Request(\\\"http://localhost/auth/me\\\", { headers: withCookie }));\\n expect(((await me.json()) as { user: { email: string } }).user.email).toBe(email);\\n\\n const logout = await post(\\\"/auth/logout\\\", {}, { headers: withCookie });\\n expect(logout.status).toBe(200);\\n\\n const meAfter = await app.fetch(new Request(\\\"http://localhost/auth/me\\\", { headers: withCookie }));\\n expect(((await meAfter.json()) as { user: null }).user).toBeNull();\\n });\\n\\n it(\\\"rejects expired sessions\\\", async () => {\\n const user = await createUser(uniqueEmail(), \\\"password123\\\");\\n const token = await createSession(user.id);\\n await db\\n .update(sessions)\\n .set({ expiresAt: new Date(Date.now() - 1000).toISOString() })\\n .where(eq(sessions.tokenHash, hashToken(token)));\\n expect(await sessionUser(token)).toBeNull();\\n });\\n\\n it(\\\"blocks cross-origin form posts (CSRF)\\\", async () => {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/auth/logout\\\", {\\n method: \\\"POST\\\",\\n headers: {\\n \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\",\\n origin: \\\"https://evil.example\\\",\\n \\\"x-forwarded-for\\\": uniqueIp(),\\n },\\n body: \\\"a=1\\\",\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n});\\n\\ndescribe(\\\"login links\\\", () => {\\n it(\\\"answers uniformly whether or not the email exists\\\", async () => {\\n const email = uniqueEmail();\\n await post(\\\"/auth/register\\\", { email, password: \\\"password123\\\" });\\n const known = await post(\\\"/auth/login-link\\\", { email });\\n const unknown = await post(\\\"/auth/login-link\\\", { email: uniqueEmail() });\\n expect(known.status).toBe(200);\\n expect(unknown.status).toBe(200);\\n expect(await known.json()).toEqual(await unknown.json());\\n });\\n\\n it(\\\"issues high-entropy single-use tokens that log the user in once\\\", async () => {\\n const email = uniqueEmail();\\n const user = await createUser(email, null);\\n const token = await createLoginToken(email);\\n expect(token).not.toBeNull();\\n // 32 random bytes, base64url: 43 characters, unique per issue.\\n expect(token!.length).toBeGreaterThanOrEqual(43);\\n expect(await createLoginToken(email)).not.toBe(token);\\n\\n const consumed = await consumeLoginToken(token!);\\n expect(consumed?.id).toBe(user.id);\\n expect(await consumeLoginToken(token!)).toBeNull();\\n });\\n\\n it(\\\"verify endpoint consumes the token and starts a session\\\", async () => {\\n const email = uniqueEmail();\\n await createUser(email, null);\\n const token = await createLoginToken(email);\\n const res = await app.fetch(\\n new Request(`http://localhost/auth/verify?token=${token}`, {\\n headers: { \\\"x-forwarded-for\\\": uniqueIp() },\\n })\\n );\\n expect(res.status).toBe(302);\\n expect(sessionTokenFrom(res).length).toBeGreaterThan(0);\\n\\n const again = await app.fetch(\\n new Request(`http://localhost/auth/verify?token=${token}`, {\\n headers: { \\\"x-forwarded-for\\\": uniqueIp() },\\n })\\n );\\n expect(again.status).toBe(400);\\n });\\n\\n it(\\\"rejects expired login tokens\\\", async () => {\\n const email = uniqueEmail();\\n await createUser(email, null);\\n const token = await createLoginToken(email);\\n const { loginTokens } = await import(\\\"../src/db/schema-auth\\\");\\n await db\\n .update(loginTokens)\\n .set({ expiresAt: new Date(Date.now() - 1000).toISOString() })\\n .where(eq(loginTokens.tokenHash, hashToken(token!)));\\n expect(await consumeLoginToken(token!)).toBeNull();\\n });\\n});\\n\\ndescribe(\\\"login-link delivery seam\\\", () => {\\n it(\\\"refuses to emit links unless NODE_ENV is explicitly development\\\", async () => {\\n const original = process.env.NODE_ENV;\\n try {\\n delete process.env.NODE_ENV;\\n await expect(deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\")).rejects.toThrow(\\n \\\"not wired\\\"\\n );\\n process.env.NODE_ENV = \\\"production\\\";\\n await expect(deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\")).rejects.toThrow(\\n \\\"not wired\\\"\\n );\\n process.env.NODE_ENV = \\\"development\\\";\\n await deliverLoginLink(\\\"user@example.com\\\", \\\"https://app.example/x\\\");\\n } finally {\\n if (original === undefined) delete process.env.NODE_ENV;\\n else process.env.NODE_ENV = original;\\n }\\n });\\n});\\n\\ndescribe(\\\"rate limiter\\\", () => {\\n it(\\\"enforces the window and resets after it passes\\\", () => {\\n const start = 1_000_000;\\n for (let i = 0; i < 3; i++) {\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + i)).toBe(true);\\n }\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + 3)).toBe(false);\\n expect(rateLimit(\\\"test:key\\\", 3, 1000, start + 1001)).toBe(true);\\n });\\n\\n it(\\\"stays bounded under attacker-minted keys\\\", () => {\\n const start = 2_000_000;\\n // Well past the 10,000-window cap; must neither throw nor block fresh keys.\\n for (let i = 0; i < 10_500; i++) {\\n expect(rateLimit(`flood:${i}`, 3, 60_000, start + i)).toBe(true);\\n }\\n expect(rateLimit(\\\"flood:final\\\", 3, 60_000, start + 11_000)).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"csrfOptions\\\", () => {\\n it(\\\"pins the CSRF origin to APP_ORIGIN so browser form posts work behind a TLS proxy\\\", async () => {\\n process.env.APP_ORIGIN = \\\"https://app.example.com\\\";\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(csrfOptions()).toEqual({ origin: \\\"https://app.example.com\\\" });\\n const { Hono } = await import(\\\"hono\\\");\\n const { csrf } = await import(\\\"hono/csrf\\\");\\n const probe = new Hono();\\n probe.use(csrf(csrfOptions()));\\n probe.post(\\\"/x\\\", (c) => c.text(\\\"ok\\\"));\\n const form = (origin: string) =>\\n new Request(\\\"http://app.example.com/x\\\", {\\n method: \\\"POST\\\",\\n headers: { origin, \\\"content-type\\\": \\\"application/x-www-form-urlencoded\\\" },\\n body: \\\"a=1\\\",\\n });\\n // Request URL is http:// (what the app sees behind Caddy); the browser\\n // sends the https origin. Default csrf() rejects this; pinned passes.\\n expect((await probe.fetch(form(\\\"https://app.example.com\\\"))).status).toBe(200);\\n expect((await probe.fetch(form(\\\"https://evil.example\\\"))).status).toBe(403);\\n } finally {\\n delete process.env.APP_ORIGIN;\\n }\\n });\\n\\n it(\\\"keeps the same-origin default when APP_ORIGIN is unset\\\", async () => {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(csrfOptions()).toEqual({});\\n });\\n});\\n\\ndescribe(\\\"csrfOptions hardening\\\", () => {\\n it(\\\"rejects non-http(s) APP_ORIGIN (origin would serialize to null)\\\", async () => {\\n process.env.APP_ORIGIN = \\\"ftp://app.example.com\\\";\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(() => csrfOptions()).toThrow(\\\"http(s)\\\");\\n } finally {\\n delete process.env.APP_ORIGIN;\\n }\\n });\\n\\n it(\\\"fails loud when APP_ORIGIN is unset in production\\\", async () => {\\n const previous = process.env.NODE_ENV;\\n process.env.NODE_ENV = \\\"production\\\";\\n delete process.env.APP_ORIGIN;\\n try {\\n const { csrfOptions } = await import(\\\"../src/lib/auth\\\");\\n expect(() => csrfOptions()).toThrow(\\\"APP_ORIGIN must be set\\\");\\n } finally {\\n process.env.NODE_ENV = previous;\\n }\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/env.ts\",\"find\":\" DB_PATH: z.string().min(1).default(\\\"data/app.db\\\"),\",\"insert\":\" // Auth extension (shibumi add auth): canonical origin for login links,\\n // e.g. https://app.example.com. Required in production; development\\n // falls back to the request origin.\\n APP_ORIGIN: z.url().optional(),\"},{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { authRoutes } from \\\"./routes/auth\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/auth\\\", authRoutes);\"},{\"file\":\"src/db/index.ts\",\"find\":\"import * as schema from \\\"./schema\\\";\",\"insert\":\"import * as authSchema from \\\"./schema-auth\\\";\"},{\"file\":\"src/db/index.ts\",\"find\":\"export const db = drizzle(sqlite, { schema });\",\"replace\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } });\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Auth extension (bun run shibumi add auth): CSRF-protected and\\n // rate-limited; covered by test/auth.test.ts.\\n \\\"ALL /auth/*\\\",\\n \\\"POST /auth/register\\\",\\n \\\"POST /auth/login\\\",\\n \\\"POST /auth/login-link\\\",\\n \\\"GET /auth/verify\\\",\\n \\\"POST /auth/logout\\\",\\n \\\"GET /auth/me\\\",\"}],\"migration\":\"CREATE TABLE users (\\n id INTEGER PRIMARY KEY AUTOINCREMENT,\\n email TEXT NOT NULL UNIQUE CHECK (length(email) BETWEEN 3 AND 254),\\n password_hash TEXT,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE TABLE sessions (\\n token_hash TEXT PRIMARY KEY,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n expires_at TEXT NOT NULL,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX sessions_user_id ON sessions (user_id);\\nCREATE INDEX sessions_expires_at ON sessions (expires_at);\\n\\nCREATE TABLE login_tokens (\\n token_hash TEXT PRIMARY KEY,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n expires_at TEXT NOT NULL,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX login_tokens_user_id ON login_tokens (user_id);\\nCREATE INDEX login_tokens_expires_at ON login_tokens (expires_at);\\n\",\"agentsFile\":\"# Auth extension\\n\\nInstalled by `bun run shibumi add auth`. This project owns every file below; edit them like any other source.\\n\\n## Files\\n\\n- `src/lib/auth.ts`: users, sessions, login tokens, rate limiter, `requireAuth` / `optionalAuth` middleware, and the login-link delivery seam.\\n- `src/routes/auth.ts`: JSON endpoints mounted at `/auth`.\\n- `src/db/schema-auth.ts`: Drizzle schema for `users`, `sessions`, `login_tokens`.\\n- `src/db/migrations/<n>_auth.sql`: the tables, numbered into this project's migration stream at install time.\\n- `test/auth.test.ts`: register/login/session/login-link/CSRF/rate-limit coverage.\\n\\n## Config\\n\\nEditable knobs live in `src/config/auth.yaml` (bundled into the image at build; edit and re-deploy to apply): `session_days` (7), `login_link_minutes` (15), `password_min_length` (8), and the per-IP `register_rate_per_15min` / `login_rate_per_15min` / `login_link_rate_per_15min`. `src/lib/auth.ts` validates them at startup and refuses to boot on a bad value. Secondary per-email rate buckets, the body cap, and the tracked-window cap stay fixed in code.\\n\\n## Endpoints\\n\\n- `POST /auth/register` `{ email, password }` → 201, sets session cookie. Password 8 to 128 chars, hashed with `Bun.password` (argon2id).\\n- `POST /auth/login` `{ email, password }` → 200 or a uniform 401. Rate limited per IP + email (10 per 15 min).\\n- `POST /auth/login-link` `{ email }` → uniform 200 whether or not the account exists (no enumeration). Rate limited per IP (5 per 15 min).\\n- `GET /auth/verify?token=...` → consumes the single-use token (15 min expiry), sets session cookie, redirects to `/`.\\n- `POST /auth/logout` → destroys the session, clears the cookie.\\n- `GET /auth/me` → `{ user }` or `{ user: null }`.\\n\\n## Session model\\n\\n- Cookie `session`: HttpOnly, Secure, SameSite=Lax, Path=/, 7-day expiry. Browsers treat localhost as a secure context, so Secure works in development.\\n- The database stores sha256 hashes of session and login tokens, never the tokens. A leaked database cannot mint logins.\\n- Protect routes with `requireAuth` (401 when signed out) or `optionalAuth`:\\n\\n```ts\\nimport { requireAuth } from \\\"./lib/auth\\\";\\napp.use(\\\"/account/*\\\", requireAuth);\\n```\\n\\n## Environment\\n\\n- `APP_ORIGIN` (validated in `src/env.ts`): canonical origin for login links, e.g. `https://app.example.com`. Login-link building is fail-closed: only `NODE_ENV=development` falls back to the request origin; every other value (production, or unset) requires `APP_ORIGIN` and requires it to be `https`, so a poisoned Host header can never redirect tokens and tokens never ride plaintext. The generated Dockerfile sets `NODE_ENV=production`; keep it set in every deployment. Set the value on the server with `bun ship:env set APP_ORIGIN=https://app.example.com`, then `bun ship`.\\n\\n## Reserved emails\\n\\nIf `ADMIN_EMAILS` is set (by the admin extension), those addresses are privileged and cannot be created through `/auth/register` (it returns `403`). They can still sign in via the login link, which proves inbox control. This stops an attacker from registering the admin address first. No effect when `ADMIN_EMAILS` is unset.\\n\\n## Honeypot\\n\\n`register`, `login`, and `login-link` accept an optional decoy field named `website`. Real clients omit it (or send it empty); render it in HTML forms as a visually hidden input. A non-empty value marks the request as a bot: the response stays plausible (fake 201, uniform 401, uniform 200) while no account, session, or token is created.\\n\\n## CSRF and rate limiting\\n\\n- `hono/csrf` runs on every `/auth` mutation (Origin check on form-shaped posts); cross-origin JSON is stopped by the browser preflight.\\n- The rate limiter is in-memory and per-process, bounded at 10,000 tracked windows (oldest evicted beyond that). That matches the single-container deployment; counts reset on restart. Replace it before scaling to multiple processes.\\n- Rate keys prefer the socket peer address (`getConnInfo`). `x-forwarded-for` is trusted only when the direct peer is a local reverse proxy (the shibumi-server / Caddy deployment) or when there is no socket peer (test context), so a directly reachable app cannot be spoofed into rotating buckets.\\n\\n## Accepted tradeoffs\\n\\n- Registration returns 409 for a taken email (standard enumeration tradeoff; registration reveals existence by nature). The login and login-link flows stay uniform in their responses; a registered email still costs marginally more server time on login-link requests.\\n- `GET /auth/verify` changes state (sets the session); that is inherent to email login links. Tokens are single-use, 15-minute, and sha256-hashed at rest. A mail scanner that prefetches the link consumes the token before the user clicks; if that affects your users, serve a confirm page that POSTs the token instead.\\n- The login-link token rides in the URL query, so it can land in proxy access logs and browser history. Single use plus the 15-minute expiry bound the exposure; anyone who can read your access logs in real time has bigger levers.\\n- Rate limits: per IP+email and per email (50/15 min) on login, per IP (5/15 min) and per email (5/15 min, uniform response) on login-link. IP keys trust `x-forwarded-for` and are only meaningful behind the deployment proxy.\\n\\n## Wiring login-link delivery\\n\\n`deliverLoginLink` in `src/lib/auth.ts` logs the URL only when `NODE_ENV=development` and throws otherwise until wired. With the email extension installed:\\n\\n```ts\\nimport { sendEmail } from \\\"./email\\\";\\n\\nexport async function deliverLoginLink(email: string, url: string): Promise<void> {\\n await sendEmail({\\n to: email,\\n subject: \\\"Your login link\\\",\\n html: `<p><a href=\\\"${url}\\\">Log in</a> (expires in 15 minutes, single use).</p>`,\\n });\\n}\\n```\\n\\n## Removal\\n\\n`bun run shibumi remove auth` deletes the installed code and reverses the edits. Tables are never dropped by tooling; when the migration already ran somewhere, drop manually with:\\n\\n```sql\\nDROP TABLE login_tokens; DROP TABLE sessions; DROP TABLE users;\\n```\\n\",\"rootSection\":\"## Auth extension\\n\\nInstalled by `bun run shibumi add auth`; full guide in `agents/auth.md`.\\n\\n- Routes under `/auth` (register, login, login-link, verify, logout, me) live in `src/routes/auth.ts`; core logic and `requireAuth`/`optionalAuth` middleware in `src/lib/auth.ts`.\\n- Sessions: HttpOnly Secure cookie; the database stores sha256 token hashes in `sessions`, never tokens.\\n- Tables `users`, `sessions`, `login_tokens` come from the installed migration; Drizzle schema in `src/db/schema-auth.ts`.\\n- Login-link delivery is a seam (`deliverLoginLink`): development logs the URL, production throws until wired to the email extension (snippet in agents/auth.md).\\n- Honeypot: the optional `website` field on register/login/login-link marks bots; non-empty values get plausible responses with no work done.\\n- Removal deletes code only; tables stay. Manual drop statements are in agents/auth.md.\",\"removeNote\":\"Tables users, sessions, and login_tokens stay in app.db wherever the migration already ran. Drop them manually if unwanted: DROP TABLE login_tokens; DROP TABLE sessions; DROP TABLE users;\"},{\"name\":\"email\",\"title\":\"Email\",\"description\":\"Transactional email via Resend's HTTP API: send helper, safe templates, webhook verification\",\"version\":\"1.0.0\",\"requires\":null,\"env\":[\"RESEND_API_KEY\",\"EMAIL_FROM\",\"RESEND_WEBHOOK_SECRET\"],\"files\":[{\"to\":\"src/config/email.yaml\",\"content\":\"# Email extension config. Bundled into the image at build time; edit and\\n# re-deploy to apply. Validated at startup by src/lib/email.ts.\\n\\n# How far a Resend webhook timestamp may be from now, in seconds, before the\\n# signature is rejected (replay window). Lower is stricter.\\nwebhook_tolerance_seconds: 300\\n\"},{\"to\":\"src/lib/email.ts\",\"content\":\"// Transactional email via Resend's HTTP API. Installed by\\n// `bun run shibumi add email`; this project owns the file. One fetch, no SDK\\n// dependency. Env (validated in src/env.ts): RESEND_API_KEY and EMAIL_FROM\\n// are required at send time, RESEND_WEBHOOK_SECRET only for webhooks.\\nimport { createHmac, timingSafeEqual } from \\\"node:crypto\\\";\\nimport { loadEnv } from \\\"../env\\\";\\n// Editable knobs live in config/email.yaml; Bun bundles the parsed values into\\n// the image at build time. Validated here at module load.\\nimport rawEmailConfig from \\\"../config/email.yaml\\\";\\n\\nconst RESEND_ENDPOINT = \\\"https://api.resend.com/emails\\\";\\n\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`email config: ${key} must be a positive integer (config/email.yaml)`);\\n }\\n return value;\\n}\\n\\nconst emailConfig = (rawEmailConfig ?? {}) as Record<string, unknown>;\\nexport const WEBHOOK_TOLERANCE_SECONDS = positiveInt(emailConfig, \\\"webhook_tolerance_seconds\\\");\\n\\nexport interface SendEmailInput {\\n to: string;\\n subject: string;\\n html?: string;\\n text?: string;\\n /** Defaults to EMAIL_FROM. */\\n from?: string;\\n}\\n\\nexport interface SendEmailResult {\\n id: string;\\n}\\n\\nexport type Fetcher = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;\\n\\n// `fetcher` exists for tests; production callers use the default.\\nexport async function sendEmail(\\n input: SendEmailInput,\\n fetcher: Fetcher = fetch\\n): Promise<SendEmailResult> {\\n const env = loadEnv();\\n if (!env.RESEND_API_KEY) {\\n throw new Error(\\\"RESEND_API_KEY is not set. Add it to the environment before sending email.\\\");\\n }\\n const from = input.from ?? env.EMAIL_FROM;\\n if (!from) {\\n throw new Error(\\\"No sender address. Set EMAIL_FROM or pass `from` explicitly.\\\");\\n }\\n if (!input.html && !input.text) {\\n throw new Error(\\\"Provide html or text content for the email.\\\");\\n }\\n const response = await fetcher(RESEND_ENDPOINT, {\\n method: \\\"POST\\\",\\n headers: {\\n authorization: `Bearer ${env.RESEND_API_KEY}`,\\n \\\"content-type\\\": \\\"application/json\\\",\\n },\\n body: JSON.stringify({\\n from,\\n to: [input.to],\\n subject: input.subject,\\n html: input.html,\\n text: input.text,\\n }),\\n });\\n if (!response.ok) {\\n const detail = (await response.text().catch(() => \\\"\\\")).slice(0, 300);\\n throw new Error(`Resend rejected the send: ${response.status} ${detail}`);\\n }\\n const data = (await response.json()) as { id?: string };\\n if (!data.id) {\\n throw new Error(\\\"Resend responded without a message id.\\\");\\n }\\n return { id: data.id };\\n}\\n\\nexport function escapeHtml(value: string): string {\\n return value\\n .replaceAll(\\\"&\\\", \\\"&amp;\\\")\\n .replaceAll(\\\"<\\\", \\\"&lt;\\\")\\n .replaceAll(\\\">\\\", \\\"&gt;\\\")\\n .replaceAll('\\\"', \\\"&quot;\\\")\\n .replaceAll(\\\"'\\\", \\\"&#39;\\\");\\n}\\n\\n// Fills {{name}} placeholders, HTML-escaping every value. Throws on a\\n// placeholder without a value, and on placeholder names outside \\\\w+ (they\\n// would pass through silently), so typos fail in tests, not in inboxes.\\nexport function renderTemplate(template: string, vars: Record<string, string | number>): string {\\n for (const match of template.matchAll(/\\\\{\\\\{([^{}]*)\\\\}\\\\}/g)) {\\n if (!/^\\\\w+$/.test(match[1] ?? \\\"\\\")) {\\n throw new Error(`Invalid template placeholder ${match[0]}; names must match \\\\\\\\w+.`);\\n }\\n }\\n return template.replaceAll(/\\\\{\\\\{(\\\\w+)\\\\}\\\\}/g, (_whole, name: string) => {\\n const value = vars[name];\\n if (value === undefined) {\\n throw new Error(`Missing template variable \\\"${name}\\\".`);\\n }\\n return escapeHtml(String(value));\\n });\\n}\\n\\n// Verifies a Resend webhook (svix format): HMAC-SHA256 over\\n// \\\"<id>.<timestamp>.<rawBody>\\\" with the base64 part of the whsec_ secret,\\n// constant-time compare, 5-minute timestamp tolerance. Pass the raw request\\n// body string, not parsed JSON.\\nexport function verifyResendWebhook(\\n rawBody: string,\\n headers: Record<string, string | undefined>,\\n secret: string,\\n nowMs = Date.now()\\n): boolean {\\n const id = headers[\\\"svix-id\\\"];\\n const timestamp = headers[\\\"svix-timestamp\\\"];\\n const signatures = headers[\\\"svix-signature\\\"];\\n if (!id || !timestamp || !signatures) return false;\\n const seconds = Number(timestamp);\\n if (!Number.isFinite(seconds) || Math.abs(nowMs / 1000 - seconds) > WEBHOOK_TOLERANCE_SECONDS) return false;\\n const key = Buffer.from(secret.replace(/^whsec_/, \\\"\\\"), \\\"base64\\\");\\n if (key.length === 0) return false;\\n const expected = createHmac(\\\"sha256\\\", key).update(`${id}.${timestamp}.${rawBody}`).digest();\\n for (const candidate of signatures.split(\\\" \\\")) {\\n const [version, value] = candidate.split(\\\",\\\", 2);\\n if (version !== \\\"v1\\\" || !value) continue;\\n const provided = Buffer.from(value, \\\"base64\\\");\\n if (provided.length === expected.length && timingSafeEqual(provided, expected)) return true;\\n }\\n return false;\\n}\\n\"},{\"to\":\"test/email.test.ts\",\"content\":\"import { createHmac } from \\\"node:crypto\\\";\\nimport { describe, expect, it } from \\\"bun:test\\\";\\n\\n// loadEnv reads process.env on each call; set the email vars before import\\n// so sends are configured for the whole file.\\nprocess.env.RESEND_API_KEY = \\\"re_test_key\\\";\\nprocess.env.EMAIL_FROM = \\\"App <app@example.com>\\\";\\nconst { escapeHtml, renderTemplate, sendEmail, verifyResendWebhook } = await import(\\n \\\"../src/lib/email\\\"\\n);\\n\\ninterface RecordedRequest {\\n url: string;\\n init: RequestInit;\\n}\\n\\nfunction fetcherReturning(status: number, body: unknown, recorded: RecordedRequest[] = []) {\\n return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {\\n recorded.push({ url: String(url), init: init ?? {} });\\n return new Response(JSON.stringify(body), { status });\\n };\\n}\\n\\ndescribe(\\\"sendEmail\\\", () => {\\n it(\\\"posts the payload to Resend with the bearer key and returns the id\\\", async () => {\\n const recorded: RecordedRequest[] = [];\\n const result = await sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Hello\\\", html: \\\"<p>Hi</p>\\\" },\\n fetcherReturning(200, { id: \\\"email_123\\\" }, recorded)\\n );\\n expect(result.id).toBe(\\\"email_123\\\");\\n expect(recorded.length).toBe(1);\\n expect(recorded[0]!.url).toBe(\\\"https://api.resend.com/emails\\\");\\n const headers = recorded[0]!.init.headers as Record<string, string>;\\n expect(headers.authorization).toBe(\\\"Bearer re_test_key\\\");\\n const payload = JSON.parse(String(recorded[0]!.init.body));\\n expect(payload).toEqual({\\n from: \\\"App <app@example.com>\\\",\\n to: [\\\"user@example.com\\\"],\\n subject: \\\"Hello\\\",\\n html: \\\"<p>Hi</p>\\\",\\n });\\n });\\n\\n it(\\\"prefers an explicit from address\\\", async () => {\\n const recorded: RecordedRequest[] = [];\\n await sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Hi\\\", text: \\\"hi\\\", from: \\\"Other <other@example.com>\\\" },\\n fetcherReturning(200, { id: \\\"email_1\\\" }, recorded)\\n );\\n expect(JSON.parse(String(recorded[0]!.init.body)).from).toBe(\\\"Other <other@example.com>\\\");\\n });\\n\\n it(\\\"requires content and surfaces Resend rejections\\\", async () => {\\n await expect(\\n sendEmail({ to: \\\"user@example.com\\\", subject: \\\"Empty\\\" }, fetcherReturning(200, { id: \\\"x\\\" }))\\n ).rejects.toThrow(\\\"html or text\\\");\\n await expect(\\n sendEmail(\\n { to: \\\"user@example.com\\\", subject: \\\"Nope\\\", text: \\\"hi\\\" },\\n fetcherReturning(422, { message: \\\"invalid\\\" })\\n )\\n ).rejects.toThrow(\\\"422\\\");\\n });\\n});\\n\\ndescribe(\\\"renderTemplate\\\", () => {\\n it(\\\"fills variables and escapes HTML in values\\\", () => {\\n const html = renderTemplate(\\\"<p>Hello {{name}}, your code is {{code}}</p>\\\", {\\n name: '<b>\\\"Ada\\\" & Co</b>',\\n code: 1234,\\n });\\n expect(html).toBe(\\n \\\"<p>Hello &lt;b&gt;&quot;Ada&quot; &amp; Co&lt;/b&gt;, your code is 1234</p>\\\"\\n );\\n });\\n\\n it(\\\"throws on a missing variable\\\", () => {\\n expect(() => renderTemplate(\\\"Hi {{name}}\\\", {})).toThrow('Missing template variable \\\"name\\\"');\\n });\\n\\n it(\\\"throws on placeholder names that would silently pass through\\\", () => {\\n expect(() => renderTemplate(\\\"Hi {{first-name}}\\\", { name: \\\"x\\\" })).toThrow(\\\"Invalid template placeholder\\\");\\n });\\n\\n it(\\\"escapes all HTML-significant characters\\\", () => {\\n expect(escapeHtml(`&<>\\\"'`)).toBe(\\\"&amp;&lt;&gt;&quot;&#39;\\\");\\n });\\n});\\n\\ndescribe(\\\"verifyResendWebhook\\\", () => {\\n const secretBytes = Buffer.from(\\\"webhook-secret-key-for-tests\\\");\\n const secret = `whsec_${secretBytes.toString(\\\"base64\\\")}`;\\n\\n function sign(id: string, timestamp: string, body: string): string {\\n return createHmac(\\\"sha256\\\", secretBytes).update(`${id}.${timestamp}.${body}`).digest(\\\"base64\\\");\\n }\\n\\n it(\\\"accepts a valid signature and rejects tampered bodies\\\", () => {\\n const body = '{\\\"type\\\":\\\"email.delivered\\\"}';\\n const nowMs = 1_700_000_000_000;\\n const timestamp = String(nowMs / 1000);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": timestamp,\\n \\\"svix-signature\\\": `v1,${sign(\\\"msg_1\\\", timestamp, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(true);\\n expect(verifyResendWebhook('{\\\"type\\\":\\\"forged\\\"}', headers, secret, nowMs)).toBe(false);\\n });\\n\\n it(\\\"rejects stale timestamps and missing headers\\\", () => {\\n const body = \\\"{}\\\";\\n const nowMs = 1_700_000_000_000;\\n const staleTs = String(nowMs / 1000 - 600);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": staleTs,\\n \\\"svix-signature\\\": `v1,${sign(\\\"msg_1\\\", staleTs, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(false);\\n expect(verifyResendWebhook(body, {}, secret, nowMs)).toBe(false);\\n });\\n\\n it(\\\"accepts any valid entry in a multi-signature header\\\", () => {\\n const body = \\\"{}\\\";\\n const nowMs = 1_700_000_000_000;\\n const timestamp = String(nowMs / 1000);\\n const headers = {\\n \\\"svix-id\\\": \\\"msg_1\\\",\\n \\\"svix-timestamp\\\": timestamp,\\n \\\"svix-signature\\\": `v1,${Buffer.from(\\\"wrong\\\").toString(\\\"base64\\\")} v1,${sign(\\\"msg_1\\\", timestamp, body)}`,\\n };\\n expect(verifyResendWebhook(body, headers, secret, nowMs)).toBe(true);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/env.ts\",\"find\":\"const schema = z.object({\",\"insert\":\" // Email extension (shibumi add email): checked at send time, not boot.\\n RESEND_API_KEY: z.string().min(1).optional(),\\n EMAIL_FROM: z.string().min(3).optional(),\\n RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),\"}],\"migration\":null,\"agentsFile\":\"# Email extension\\n\\nInstalled by `bun run shibumi add email`. This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/email.ts`: `sendEmail`, `renderTemplate`, `escapeHtml`, `verifyResendWebhook`. Plain fetch to Resend's HTTP API; no SDK dependency.\\n- `test/email.test.ts`: send payload, template rendering and escaping, webhook signature coverage. Uses an injected fetcher; no network.\\n\\n## Config\\n\\n`src/config/email.yaml` (bundled at build) holds `webhook_tolerance_seconds` (default 300): how far a webhook timestamp may be from now before the signature is rejected. Validated at startup.\\n\\n## Environment\\n\\nValidated in `src/env.ts`, all optional at boot and checked at use:\\n\\n- `RESEND_API_KEY`: required to send. Set it on the server with `bun ship:env set RESEND_API_KEY=...` (never in code or git), then `bun ship`.\\n- `EMAIL_FROM`: default sender, e.g. `App <app@yourdomain.com>`. The domain must be verified in Resend.\\n- `RESEND_WEBHOOK_SECRET`: only for webhook verification (`whsec_...`).\\n\\n## Sending\\n\\n```ts\\nimport { renderTemplate, sendEmail } from \\\"./lib/email\\\";\\n\\nawait sendEmail({\\n to: \\\"user@example.com\\\",\\n subject: \\\"Welcome\\\",\\n html: renderTemplate(\\\"<p>Hello {{name}}</p>\\\", { name: user.name }),\\n});\\n```\\n\\n`renderTemplate` HTML-escapes every variable and throws on a missing one; never interpolate user input into email HTML directly.\\n\\n## Webhooks\\n\\nResend webhooks are svix-signed. Verify with the raw body string before parsing:\\n\\n```ts\\napp.post(\\\"/webhooks/resend\\\", async (c) => {\\n const raw = await c.req.text();\\n const env = loadEnv();\\n if (!env.RESEND_WEBHOOK_SECRET || !verifyResendWebhook(raw, {\\n \\\"svix-id\\\": c.req.header(\\\"svix-id\\\"),\\n \\\"svix-timestamp\\\": c.req.header(\\\"svix-timestamp\\\"),\\n \\\"svix-signature\\\": c.req.header(\\\"svix-signature\\\"),\\n }, env.RESEND_WEBHOOK_SECRET)) {\\n return c.json({ error: \\\"Invalid signature\\\" }, 401);\\n }\\n const event = JSON.parse(raw);\\n // handle event.type: email.delivered, email.bounced, ...\\n return c.json({ ok: true });\\n});\\n```\\n\\nSignature verification does not stop replays inside the 5-minute tolerance window: if a webhook triggers side effects, record processed `svix-id` values and skip duplicates.\\n\\n## Removal\\n\\n`bun run shibumi remove email` deletes the installed code and reverses the `src/env.ts` edit. No tables are involved.\\n\",\"rootSection\":\"## Email extension\\n\\nInstalled by `bun run shibumi add email`; full guide in `agents/email.md`.\\n\\n- `src/lib/email.ts`: `sendEmail` (plain fetch to Resend, no SDK), `renderTemplate` (HTML-escapes every variable), `verifyResendWebhook` (svix HMAC, constant-time).\\n- Env: `RESEND_API_KEY` and `EMAIL_FROM` required at send time, `RESEND_WEBHOOK_SECRET` for webhooks; all validated in `src/env.ts`, none required at boot.\\n- Verify webhooks against the raw body string before parsing (snippet in agents/email.md).\\n- No tables; removal deletes the code and reverses the env edit.\",\"removeNote\":\"No tables are involved; remove any RESEND_* variables from the deployment environment when no longer needed.\"},{\"name\":\"uploads\",\"title\":\"Uploads\",\"description\":\"Authenticated file uploads: validated multipart, content-addressed storage on the persistent volume, owner-scoped serving\",\"version\":\"1.0.1\",\"requires\":\"database\",\"dependsOn\":[\"auth\"],\"env\":[],\"files\":[{\"to\":\"src/config/uploads.yaml\",\"content\":\"# Uploads extension config. Edit and re-deploy (bun ship) to apply; the values\\n# are bundled into the image at build time. These are security limits, so keep\\n# them tight. src/lib/uploads.ts validates them at startup and refuses to boot\\n# on a bad value.\\n\\n# Largest single file, in MiB.\\nmax_file_mib: 5\\n\\n# Files accepted per upload request.\\nmax_files_per_request: 5\\n\\n# Total stored bytes per user, in MiB.\\nuser_quota_mib: 100\\n\\n# Upload requests allowed per user per 15 minutes.\\nrate_limit_per_15min: 30\\n\"},{\"to\":\"src/db/schema-uploads.ts\",\"content\":\"import { sql } from \\\"drizzle-orm\\\";\\nimport { integer, sqliteTable, text } from \\\"drizzle-orm/sqlite-core\\\";\\nimport { users } from \\\"./schema-auth\\\";\\n\\n// Owned by the uploads extension (bun run shibumi add uploads). Metadata only;\\n// bytes live on the persistent volume next to app.db. stored_name is content\\n// addressed (sha256 + sniffed extension), never a user filename.\\nexport const uploads = sqliteTable(\\\"uploads\\\", {\\n id: integer(\\\"id\\\").primaryKey({ autoIncrement: true }),\\n storedName: text(\\\"stored_name\\\").notNull(),\\n originalName: text(\\\"original_name\\\").notNull(),\\n contentType: text(\\\"content_type\\\").notNull(),\\n size: integer(\\\"size\\\").notNull(),\\n sha256: text(\\\"sha256\\\").notNull(),\\n userId: integer(\\\"user_id\\\")\\n .notNull()\\n .references(() => users.id, { onDelete: \\\"cascade\\\" }),\\n createdAt: text(\\\"created_at\\\")\\n .notNull()\\n .default(sql`(datetime('now'))`),\\n});\\n\"},{\"to\":\"src/lib/uploads.ts\",\"content\":\"// Upload storage: validate, content-address, persist, serve, delete.\\n// Installed by `bun run shibumi add uploads` (needs the auth extension).\\n// This project owns the file. Full guide: agents/uploads.md.\\n//\\n// Invariants:\\n// - A file's type is decided by sniffing its leading bytes, never by the\\n// client-supplied filename or Content-Type. Anything not on the allowlist\\n// is rejected.\\n// - On-disk names are sha256(content) + the sniffed extension, so a filename\\n// can never contain a path separator or traversal segment, and identical\\n// bytes are stored once.\\n// - Bytes live under <db-dir>/uploads, i.e. the persistent /data volume in the\\n// container; metadata lives in app.db. The two are reconciled on delete.\\nimport { createHash, randomUUID } from \\\"node:crypto\\\";\\nimport { existsSync, lstatSync, mkdirSync, renameSync, rmSync } from \\\"node:fs\\\";\\nimport { dirname, join, resolve, sep } from \\\"node:path\\\";\\nimport { and, eq, sql } from \\\"drizzle-orm\\\";\\nimport { db } from \\\"../db\\\";\\nimport { uploads } from \\\"../db/schema-uploads\\\";\\nimport { loadEnv } from \\\"../env\\\";\\n// Editable knobs live in config/uploads.yaml; Bun bundles the parsed values\\n// into the image at build time. Edit that file and re-deploy to change limits.\\nimport rawUploadsConfig from \\\"../config/uploads.yaml\\\";\\n\\n// A bad edit (missing, non-numeric, non-positive, non-integer) throws here at\\n// module load, so the container fails its health check and the previous\\n// deployment stays live rather than serving with a disabled limit.\\nfunction positiveInt(config: Record<string, unknown>, key: string): number {\\n const value = config[key];\\n if (typeof value !== \\\"number\\\" || !Number.isInteger(value) || value <= 0) {\\n throw new Error(`uploads config: ${key} must be a positive integer (config/uploads.yaml)`);\\n }\\n return value;\\n}\\n\\nconst uploadsConfig = (rawUploadsConfig ?? {}) as Record<string, unknown>;\\nconst MIB = 1024 * 1024;\\n\\nexport const MAX_FILE_BYTES = positiveInt(uploadsConfig, \\\"max_file_mib\\\") * MIB;\\nexport const MAX_FILES_PER_REQUEST = positiveInt(uploadsConfig, \\\"max_files_per_request\\\");\\n// Per-user ceiling on total stored bytes, so a self-registered account cannot\\n// fill the volume. Dedup means shared blobs are counted per referencing row,\\n// which is the conservative (higher) number.\\nexport const USER_QUOTA_BYTES = positiveInt(uploadsConfig, \\\"user_quota_mib\\\") * MIB;\\nexport const UPLOAD_RATE_LIMIT = positiveInt(uploadsConfig, \\\"rate_limit_per_15min\\\");\\nexport const UPLOAD_RATE_WINDOW_MS = 15 * 60 * 1000;\\n\\ninterface AllowedType {\\n contentType: string;\\n extension: string;\\n matches: (bytes: Uint8Array) => boolean;\\n}\\n\\nfunction startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {\\n if (bytes.length < offset + signature.length) return false;\\n return signature.every((byte, index) => bytes[offset + index] === byte);\\n}\\n\\n// Allowlist by magic bytes. Extend deliberately; every entry must have a\\n// signature so type is proven, not asserted.\\nconst ALLOWED_TYPES: AllowedType[] = [\\n { contentType: \\\"image/png\\\", extension: \\\"png\\\", matches: (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },\\n { contentType: \\\"image/jpeg\\\", extension: \\\"jpg\\\", matches: (b) => startsWith(b, [0xff, 0xd8, 0xff]) },\\n { contentType: \\\"image/gif\\\", extension: \\\"gif\\\", matches: (b) => startsWith(b, [0x47, 0x49, 0x46, 0x38]) },\\n {\\n contentType: \\\"image/webp\\\",\\n extension: \\\"webp\\\",\\n matches: (b) => startsWith(b, [0x52, 0x49, 0x46, 0x46]) && startsWith(b, [0x57, 0x45, 0x42, 0x50], 8),\\n },\\n { contentType: \\\"application/pdf\\\", extension: \\\"pdf\\\", matches: (b) => startsWith(b, [0x25, 0x50, 0x44, 0x46]) },\\n];\\n\\nexport function sniffType(bytes: Uint8Array): AllowedType | null {\\n return ALLOWED_TYPES.find((type) => type.matches(bytes)) ?? null;\\n}\\n\\nexport function uploadsDir(): string {\\n const env = loadEnv();\\n return join(dirname(env.DB_PATH), \\\"uploads\\\");\\n}\\n\\n// Resolve a stored name to an absolute path, refusing anything that is not a\\n// bare content-addressed name or that escapes the uploads directory.\\nconst STORED_NAME = /^[a-f0-9]{64}\\\\.[a-z0-9]+$/;\\nexport function resolveStored(storedName: string): string | null {\\n if (!STORED_NAME.test(storedName)) return null;\\n const base = resolve(uploadsDir());\\n const target = resolve(base, storedName);\\n if (target !== join(base, storedName) || !target.startsWith(base + sep)) return null;\\n return target;\\n}\\n\\nexport interface StoredUpload {\\n id: number;\\n storedName: string;\\n originalName: string;\\n contentType: string;\\n size: number;\\n sha256: string;\\n}\\n\\nexport interface RejectedUpload {\\n originalName: string;\\n reason: string;\\n}\\n\\nexport interface SaveResult {\\n saved: StoredUpload[];\\n rejected: RejectedUpload[];\\n}\\n\\nfunction sanitizeOriginalName(name: string): string {\\n // Kept for display only; never used on disk. Strip path separators and\\n // control chars (incl. CR/LF so it is safe in a Content-Disposition header),\\n // keep ordinary filename characters like \\\".\\\", bound the length.\\n const base =\\n name\\n .replace(/[/\\\\\\\\]/g, \\\"\\\")\\n .replace(/[\\\\x00-\\\\x1f\\\\x7f]/g, \\\"\\\")\\n .trim() || \\\"file\\\";\\n return base.slice(0, 255);\\n}\\n\\n// Validates and stores one already-read buffer. Exported for direct testing.\\nexport async function saveBuffer(\\n bytes: Uint8Array,\\n originalName: string,\\n userId: number\\n): Promise<StoredUpload> {\\n if (bytes.length === 0) throw new Error(\\\"empty file\\\");\\n if (bytes.length > MAX_FILE_BYTES) throw new Error(`file exceeds ${MAX_FILE_BYTES} bytes`);\\n const type = sniffType(bytes);\\n if (!type) throw new Error(\\\"unsupported file type\\\");\\n\\n const sha256 = createHash(\\\"sha256\\\").update(bytes).digest(\\\"hex\\\");\\n const storedName = `${sha256}.${type.extension}`;\\n const target = resolveStored(storedName);\\n if (!target) throw new Error(\\\"could not resolve a safe storage path\\\");\\n\\n const dir = uploadsDir();\\n mkdirSync(dir, { recursive: true });\\n // The storage root must be a real directory, never a symlink another\\n // principal could repoint outside the volume.\\n if (lstatSync(dir).isSymbolicLink()) throw new Error(\\\"uploads directory is a symlink\\\");\\n // Content-addressed: if the bytes already exist on disk, reuse them.\\n // Otherwise write to a unique temp file and atomically rename into place,\\n // so a crash mid-write never leaves a partial blob under the final name.\\n if (!existsSync(target)) {\\n const tmp = `${target}.tmp-${randomUUID()}`;\\n try {\\n await Bun.write(tmp, bytes);\\n renameSync(tmp, target);\\n } finally {\\n if (existsSync(tmp)) rmSync(tmp, { force: true });\\n }\\n }\\n\\n const originalNameSafe = sanitizeOriginalName(originalName);\\n const rows = await db\\n .insert(uploads)\\n .values({\\n storedName,\\n originalName: originalNameSafe,\\n contentType: type.contentType,\\n size: bytes.length,\\n sha256,\\n userId,\\n })\\n .returning();\\n const row = rows[0]!;\\n return {\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n };\\n}\\n\\nexport async function userUsageBytes(userId: number): Promise<number> {\\n const row = await db\\n .select({ total: sql<number>`coalesce(sum(${uploads.size}), 0)` })\\n .from(uploads)\\n .where(eq(uploads.userId, userId));\\n return Number(row[0]?.total ?? 0);\\n}\\n\\n// Serialize a user's uploads so the quota read-modify-write cannot interleave\\n// across concurrent requests (single-process container). Each user gets a\\n// promise chain; entries drop out once the chain drains.\\nconst userLocks = new Map<number, Promise<unknown>>();\\nexport function saveFiles(files: File[], userId: number): Promise<SaveResult> {\\n const run = (userLocks.get(userId) ?? Promise.resolve()).then(\\n () => saveFilesLocked(files, userId),\\n () => saveFilesLocked(files, userId)\\n );\\n userLocks.set(userId, run);\\n void run.finally(() => {\\n if (userLocks.get(userId) === run) userLocks.delete(userId);\\n });\\n return run;\\n}\\n\\nasync function saveFilesLocked(files: File[], userId: number): Promise<SaveResult> {\\n if (files.length === 0) throw new Error(\\\"no files provided\\\");\\n if (files.length > MAX_FILES_PER_REQUEST) {\\n throw new Error(`too many files (max ${MAX_FILES_PER_REQUEST} per request)`);\\n }\\n const saved: StoredUpload[] = [];\\n const rejected: RejectedUpload[] = [];\\n // Serialized above, so this snapshot is stable for the batch.\\n let usage = await userUsageBytes(userId);\\n for (const file of files) {\\n const originalName = sanitizeOriginalName(file.name || \\\"file\\\");\\n try {\\n // Enforce the size limit before buffering the whole file.\\n if (file.size > MAX_FILE_BYTES) throw new Error(`file exceeds ${MAX_FILE_BYTES} bytes`);\\n if (usage + file.size > USER_QUOTA_BYTES) throw new Error(\\\"storage quota exceeded\\\");\\n const bytes = new Uint8Array(await file.arrayBuffer());\\n const stored = await saveBuffer(bytes, originalName, userId);\\n usage += stored.size;\\n saved.push(stored);\\n } catch (error) {\\n rejected.push({ originalName, reason: error instanceof Error ? error.message : \\\"rejected\\\" });\\n }\\n }\\n return { saved, rejected };\\n}\\n\\nexport async function listUploads(userId: number): Promise<StoredUpload[]> {\\n const rows = await db.select().from(uploads).where(eq(uploads.userId, userId));\\n return rows.map((row) => ({\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n }));\\n}\\n\\nexport async function getUpload(id: number, userId: number): Promise<(StoredUpload & { path: string }) | null> {\\n const rows = await db\\n .select()\\n .from(uploads)\\n .where(and(eq(uploads.id, id), eq(uploads.userId, userId)));\\n const row = rows[0];\\n if (!row) return null;\\n const path = resolveStored(row.storedName);\\n if (!path) return null;\\n return {\\n id: row.id,\\n storedName: row.storedName,\\n originalName: row.originalName,\\n contentType: row.contentType,\\n size: row.size,\\n sha256: row.sha256,\\n path,\\n };\\n}\\n\\nexport async function deleteUpload(id: number, userId: number): Promise<boolean> {\\n const rows = await db\\n .delete(uploads)\\n .where(and(eq(uploads.id, id), eq(uploads.userId, userId)))\\n .returning();\\n const row = rows[0];\\n if (!row) return false;\\n // Only remove the bytes when no other row (any user) references the same\\n // content-addressed blob.\\n const others = await db.select({ id: uploads.id }).from(uploads).where(eq(uploads.storedName, row.storedName));\\n if (others.length === 0) {\\n const path = resolveStored(row.storedName);\\n if (path && existsSync(path)) await Bun.file(path).delete();\\n }\\n return true;\\n}\\n\"},{\"to\":\"src/routes/uploads.ts\",\"content\":\"// Upload routes, mounted at /uploads by the installer. Every route requires a\\n// session (uploads has no unauthenticated endpoints); CSRF covers mutations.\\n// Files are owned by the uploading user; serving is scoped to the owner.\\nimport { Hono } from \\\"hono\\\";\\nimport type { Context } from \\\"hono\\\";\\nimport { csrf } from \\\"hono/csrf\\\";\\nimport { csrfOptions, rateLimit, requireAuth, type AuthEnv } from \\\"../lib/auth\\\";\\nimport {\\n MAX_FILES_PER_REQUEST,\\n MAX_FILE_BYTES,\\n UPLOAD_RATE_LIMIT,\\n UPLOAD_RATE_WINDOW_MS,\\n deleteUpload,\\n getUpload,\\n listUploads,\\n saveFiles,\\n} from \\\"../lib/uploads\\\";\\n\\nexport const uploadRoutes = new Hono<AuthEnv>();\\n\\n// CSRF first (refuse cross-origin mutations before any session work), then a\\n// required session. Two middleware registrations, so the mounted surface\\n// carries two `ALL /uploads/*` entries.\\nuploadRoutes.use(csrf(csrfOptions()));\\nuploadRoutes.use(requireAuth);\\n\\nfunction idParam(c: Context): number | null {\\n const raw = c.req.param(\\\"id\\\") ?? \\\"\\\";\\n if (!/^\\\\d+$/.test(raw)) return null;\\n const id = Number(raw);\\n return Number.isSafeInteger(id) ? id : null;\\n}\\n\\nuploadRoutes.post(\\\"/\\\", async (c) => {\\n // Keyed on the authenticated user, so it needs no forwarded-IP trust.\\n if (!rateLimit(`uploads:${c.get(\\\"user\\\").id}`, UPLOAD_RATE_LIMIT, UPLOAD_RATE_WINDOW_MS)) {\\n return c.json({ error: \\\"Too many uploads. Try again later.\\\" }, 429);\\n }\\n let form: FormData;\\n try {\\n form = await c.req.formData();\\n } catch {\\n return c.json({ error: \\\"Expected multipart/form-data.\\\" }, 400);\\n }\\n const files = form.getAll(\\\"file\\\").filter((entry): entry is File => entry instanceof File);\\n if (files.length === 0) {\\n return c.json({ error: \\\"Attach at least one file in the 'file' field.\\\" }, 400);\\n }\\n if (files.length > MAX_FILES_PER_REQUEST) {\\n return c.json({ error: `Too many files (max ${MAX_FILES_PER_REQUEST}).` }, 400);\\n }\\n const result = await saveFiles(files, c.get(\\\"user\\\").id);\\n // All rejected and nothing saved is a client error; a partial success still\\n // reports which files were refused and why.\\n const status = result.saved.length === 0 ? 400 : 201;\\n return c.json(result, status);\\n});\\n\\nuploadRoutes.get(\\\"/\\\", async (c) => {\\n return c.json({ uploads: await listUploads(c.get(\\\"user\\\").id) });\\n});\\n\\nuploadRoutes.get(\\\"/:id\\\", async (c) => {\\n const id = idParam(c);\\n if (id === null) return c.json({ error: \\\"Invalid id.\\\" }, 400);\\n const upload = await getUpload(id, c.get(\\\"user\\\").id);\\n if (!upload) return c.json({ error: \\\"Not found.\\\" }, 404);\\n const file = Bun.file(upload.path);\\n if (!(await file.exists())) return c.json({ error: \\\"File is missing on disk.\\\" }, 410);\\n return new Response(file, {\\n headers: {\\n \\\"content-type\\\": upload.contentType,\\n \\\"content-length\\\": String(upload.size),\\n // Never render untrusted uploads inline; force a download. nosniff is\\n // also set globally, but pin it here so serving stays safe even if the\\n // app's header middleware changes.\\n \\\"content-disposition\\\": `attachment; filename=\\\"${upload.originalName.replace(/\\\"/g, \\\"\\\")}\\\"`,\\n \\\"x-content-type-options\\\": \\\"nosniff\\\",\\n \\\"cache-control\\\": \\\"private, no-store\\\",\\n },\\n });\\n});\\n\\nuploadRoutes.delete(\\\"/:id\\\", async (c) => {\\n const id = idParam(c);\\n if (id === null) return c.json({ error: \\\"Invalid id.\\\" }, 400);\\n const removed = await deleteUpload(id, c.get(\\\"user\\\").id);\\n if (!removed) return c.json({ error: \\\"Not found.\\\" }, 404);\\n return c.json({ ok: true });\\n});\\n\\nexport const UPLOAD_LIMITS = { maxFiles: MAX_FILES_PER_REQUEST, maxBytes: MAX_FILE_BYTES };\\n\"},{\"to\":\"test/uploads.test.ts\",\"content\":\"import { afterAll, describe, expect, it } from \\\"bun:test\\\";\\nimport { existsSync, mkdtempSync, rmSync } from \\\"node:fs\\\";\\nimport { tmpdir } from \\\"node:os\\\";\\nimport { join } from \\\"node:path\\\";\\n\\n// The db module opens DB_PATH at import time; point it at a scratch database\\n// whose directory also becomes the uploads root.\\nconst scratch = mkdtempSync(join(tmpdir(), \\\"uploads-test-\\\"));\\nprocess.env.DB_PATH = join(scratch, \\\"app.db\\\");\\nconst { app } = await import(\\\"../src/app\\\");\\nconst { sqlite } = await import(\\\"../src/db\\\");\\nconst { applyMigrations } = await import(\\\"../src/db/lifecycle\\\");\\nconst { createUser, createSession, SESSION_COOKIE } = await import(\\\"../src/lib/auth\\\");\\nconst {\\n MAX_FILE_BYTES,\\n USER_QUOTA_BYTES,\\n deleteUpload,\\n resolveStored,\\n saveBuffer,\\n saveFiles,\\n sniffType,\\n uploadsDir,\\n userUsageBytes,\\n} = await import(\\\"../src/lib/uploads\\\");\\nconst { db } = await import(\\\"../src/db\\\");\\nconst { uploads } = await import(\\\"../src/db/schema-uploads\\\");\\nawait applyMigrations(sqlite);\\n\\nafterAll(() => rmSync(scratch, { recursive: true, force: true }));\\n\\nconst PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);\\nconst PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]);\\n\\nlet sessionCounter = 0;\\nasync function freshSession(): Promise<{ userId: number; cookie: string }> {\\n sessionCounter += 1;\\n const user = await createUser(`up${sessionCounter}-${Date.now()}@example.com`, \\\"password123\\\");\\n const token = await createSession(user.id);\\n return { userId: user.id, cookie: `${SESSION_COOKIE}=${token}` };\\n}\\n\\nfunction multipart(files: Array<{ name: string; bytes: Uint8Array; type?: string }>): FormData {\\n const form = new FormData();\\n for (const f of files) {\\n form.append(\\\"file\\\", new File([f.bytes as BlobPart], f.name, { type: f.type ?? \\\"application/octet-stream\\\" }));\\n }\\n return form;\\n}\\n\\nasync function upload(cookie: string, form: FormData): Promise<Response> {\\n return app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", {\\n method: \\\"POST\\\",\\n headers: { cookie, origin: \\\"http://localhost\\\" },\\n body: form,\\n })\\n );\\n}\\n\\ndescribe(\\\"type sniffing\\\", () => {\\n it(\\\"recognizes allowed types by magic bytes and rejects others\\\", () => {\\n expect(sniffType(PNG)?.contentType).toBe(\\\"image/png\\\");\\n expect(sniffType(PDF)?.contentType).toBe(\\\"application/pdf\\\");\\n expect(sniffType(new Uint8Array([0x3c, 0x73, 0x76, 0x67]))).toBeNull(); // <svg\\n expect(sniffType(new Uint8Array([0x4d, 0x5a]))).toBeNull(); // PE\\n });\\n});\\n\\ndescribe(\\\"resolveStored\\\", () => {\\n it(\\\"accepts content-addressed names and rejects traversal or arbitrary names\\\", () => {\\n const hex = \\\"a\\\".repeat(64);\\n expect(resolveStored(`${hex}.png`)).toContain(uploadsDir());\\n expect(resolveStored(\\\"../secret.png\\\")).toBeNull();\\n expect(resolveStored(\\\"evil.png\\\")).toBeNull();\\n expect(resolveStored(`${hex}.png/../../etc/passwd`)).toBeNull();\\n expect(resolveStored(`${hex}`)).toBeNull();\\n });\\n});\\n\\ndescribe(\\\"saveBuffer\\\", () => {\\n it(\\\"stores content-addressed and dedupes identical bytes\\\", async () => {\\n const { userId } = await freshSession();\\n const a = await saveBuffer(PNG, \\\"one.png\\\", userId);\\n const b = await saveBuffer(PNG, \\\"two.png\\\", userId);\\n expect(a.storedName).toBe(b.storedName);\\n expect(a.storedName).toMatch(/^[a-f0-9]{64}\\\\.png$/);\\n expect(existsSync(resolveStored(a.storedName)!)).toBe(true);\\n });\\n\\n it(\\\"rejects empty, oversize, and unknown-type buffers\\\", async () => {\\n const { userId } = await freshSession();\\n await expect(saveBuffer(new Uint8Array(0), \\\"empty.png\\\", userId)).rejects.toThrow(\\\"empty\\\");\\n await expect(saveBuffer(new Uint8Array(MAX_FILE_BYTES + 1).fill(0x89), \\\"big.png\\\", userId)).rejects.toThrow(\\n \\\"exceeds\\\"\\n );\\n await expect(saveBuffer(new Uint8Array([1, 2, 3, 4, 5]), \\\"mystery.bin\\\", userId)).rejects.toThrow(\\n \\\"unsupported\\\"\\n );\\n });\\n});\\n\\ndescribe(\\\"routes\\\", () => {\\n it(\\\"requires a session\\\", async () => {\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", { headers: { \\\"x-forwarded-for\\\": \\\"10.9.9.9\\\" } })\\n );\\n expect(res.status).toBe(401);\\n });\\n\\n it(\\\"blocks cross-origin uploads (CSRF)\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await app.fetch(\\n new Request(\\\"http://localhost/uploads\\\", {\\n method: \\\"POST\\\",\\n headers: { cookie, origin: \\\"https://evil.example\\\" },\\n body: multipart([{ name: \\\"a.png\\\", bytes: PNG }]),\\n })\\n );\\n expect(res.status).toBe(403);\\n });\\n\\n it(\\\"uploads, lists, downloads (as attachment), and deletes, owner-scoped\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await upload(cookie, multipart([{ name: \\\"photo.png\\\", bytes: PNG }]));\\n expect(res.status).toBe(201);\\n const body = (await res.json()) as { saved: Array<{ id: number; contentType: string }>; rejected: unknown[] };\\n expect(body.saved.length).toBe(1);\\n expect(body.rejected.length).toBe(0);\\n const id = body.saved[0]!.id;\\n\\n const list = await app.fetch(new Request(\\\"http://localhost/uploads\\\", { headers: { cookie } }));\\n expect(((await list.json()) as { uploads: unknown[] }).uploads.length).toBe(1);\\n\\n const download = await app.fetch(new Request(`http://localhost/uploads/${id}`, { headers: { cookie } }));\\n expect(download.status).toBe(200);\\n expect(download.headers.get(\\\"content-type\\\")).toBe(\\\"image/png\\\");\\n expect(download.headers.get(\\\"content-disposition\\\")).toContain(\\\"attachment\\\");\\n\\n // A different user cannot see or fetch it.\\n const other = await freshSession();\\n const otherGet = await app.fetch(\\n new Request(`http://localhost/uploads/${id}`, { headers: { cookie: other.cookie } })\\n );\\n expect(otherGet.status).toBe(404);\\n\\n const del = await app.fetch(\\n new Request(`http://localhost/uploads/${id}`, {\\n method: \\\"DELETE\\\",\\n headers: { cookie, origin: \\\"http://localhost\\\" },\\n })\\n );\\n expect(del.status).toBe(200);\\n });\\n\\n it(\\\"rejects a disallowed type in multipart with a reason\\\", async () => {\\n const { cookie } = await freshSession();\\n const res = await upload(cookie, multipart([{ name: \\\"script.svg\\\", bytes: new Uint8Array([0x3c, 0x73, 0x76, 0x67]) }]));\\n expect(res.status).toBe(400);\\n const body = (await res.json()) as { saved: unknown[]; rejected: Array<{ reason: string }> };\\n expect(body.saved.length).toBe(0);\\n expect(body.rejected[0]!.reason).toContain(\\\"unsupported\\\");\\n });\\n\\n it(\\\"caps the number of files per request\\\", async () => {\\n const { cookie } = await freshSession();\\n const many = Array.from({ length: 6 }, (_, i) => ({ name: `f${i}.png`, bytes: PNG }));\\n const res = await upload(cookie, multipart(many));\\n expect(res.status).toBe(400);\\n expect(((await res.json()) as { error: string }).error).toContain(\\\"Too many\\\");\\n });\\n});\\n\\ndescribe(\\\"quota\\\", () => {\\n it(\\\"rejects an upload that would exceed the per-user byte quota\\\", async () => {\\n const { userId } = await freshSession();\\n // Seed usage at the quota with a metadata row (no bytes on disk needed).\\n await db.insert(uploads).values({\\n storedName: `${\\\"b\\\".repeat(64)}.png`,\\n originalName: \\\"seed.png\\\",\\n contentType: \\\"image/png\\\",\\n size: USER_QUOTA_BYTES,\\n sha256: \\\"b\\\".repeat(64),\\n userId,\\n });\\n expect(await userUsageBytes(userId)).toBe(USER_QUOTA_BYTES);\\n const result = await saveFiles([new File([PNG as BlobPart], \\\"x.png\\\", { type: \\\"image/png\\\" })], userId);\\n expect(result.saved.length).toBe(0);\\n expect(result.rejected[0]!.reason).toContain(\\\"quota\\\");\\n });\\n});\\n\\ndescribe(\\\"rate limiting\\\", () => {\\n it(\\\"429s after the per-user upload limit\\\", async () => {\\n const { cookie } = await freshSession();\\n let limited = false;\\n for (let i = 0; i < 31; i++) {\\n const res = await upload(cookie, multipart([{ name: \\\"a.png\\\", bytes: PNG }]));\\n if (res.status === 429) limited = true;\\n }\\n expect(limited).toBe(true);\\n });\\n});\\n\\ndescribe(\\\"blob lifecycle\\\", () => {\\n it(\\\"keeps the blob while another row references it, deletes when last goes\\\", async () => {\\n const first = await freshSession();\\n const second = await freshSession();\\n const a = await saveBuffer(PDF, \\\"a.pdf\\\", first.userId);\\n const b = await saveBuffer(PDF, \\\"b.pdf\\\", second.userId);\\n expect(a.storedName).toBe(b.storedName);\\n const path = resolveStored(a.storedName)!;\\n\\n expect(await deleteUpload(a.id, first.userId)).toBe(true);\\n expect(existsSync(path)).toBe(true); // second row still references it\\n expect(await deleteUpload(b.id, second.userId)).toBe(true);\\n expect(existsSync(path)).toBe(false);\\n });\\n});\\n\"}],\"hooks\":[{\"file\":\"src/app.ts\",\"find\":\"import { Hono } from \\\"hono\\\";\",\"insert\":\"import { uploadRoutes } from \\\"./routes/uploads\\\";\"},{\"file\":\"src/app.ts\",\"find\":\" app.get(\\\"/healthz\\\", (c) => c.json({ ok: true }));\",\"insert\":\"\\n app.route(\\\"/uploads\\\", uploadRoutes);\"},{\"file\":\"src/db/index.ts\",\"find\":\"import * as authSchema from \\\"./schema-auth\\\";\",\"insert\":\"import * as uploadsSchema from \\\"./schema-uploads\\\";\"},{\"file\":\"src/db/index.ts\",\"find\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } });\",\"replace\":\"export const db = drizzle(sqlite, { schema: { ...schema, ...authSchema, ...uploadsSchema } });\"},{\"file\":\"src/server.ts\",\"find\":\" maxRequestBodySize: 1024 * 1024,\",\"replace\":\" // Fixed server ceiling for the uploads extension; generous over the configurable per-file limits.\\n maxRequestBodySize: 55 * 1024 * 1024,\"},{\"file\":\"test/app.test.ts\",\"find\":\" \\\"GET /healthz\\\",\",\"insert\":\" // Uploads extension (bun run shibumi add uploads): every route is\\n // session-guarded and CSRF-protected; covered by test/uploads.test.ts.\\n // Two ALL entries: the CSRF and requireAuth middlewares.\\n \\\"ALL /uploads/*\\\",\\n \\\"ALL /uploads/*\\\",\\n \\\"POST /uploads\\\",\\n \\\"GET /uploads\\\",\\n \\\"GET /uploads/:id\\\",\\n \\\"DELETE /uploads/:id\\\",\"}],\"migration\":\"CREATE TABLE uploads (\\n id INTEGER PRIMARY KEY AUTOINCREMENT,\\n -- Content-addressed name on disk (sha256 hex + sniffed extension); never a\\n -- user-supplied filename.\\n stored_name TEXT NOT NULL,\\n original_name TEXT NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255),\\n content_type TEXT NOT NULL,\\n size INTEGER NOT NULL CHECK (size >= 0),\\n sha256 TEXT NOT NULL,\\n user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\\n);\\n\\nCREATE INDEX uploads_user_id ON uploads (user_id);\\nCREATE INDEX uploads_stored_name ON uploads (stored_name);\\n\",\"agentsFile\":\"# Uploads extension\\n\\nInstalled by `bun run shibumi add uploads`. Needs the auth extension (every route requires a session). This project owns every file below.\\n\\n## Files\\n\\n- `src/lib/uploads.ts`: validation, content-addressed storage, listing, owner-scoped retrieval, deletion.\\n- `src/routes/uploads.ts`: JSON + file endpoints mounted at `/uploads`.\\n- `src/db/schema-uploads.ts`: Drizzle schema for the `uploads` metadata table.\\n- `src/db/migrations/<n>_uploads.sql`: the table, numbered into this project's migration stream at install time.\\n- `test/uploads.test.ts`: validation, storage, serving, deletion, and traversal-safety coverage.\\n\\n## Endpoints (all require a session)\\n\\n- `POST /uploads` multipart form, field `file` (repeatable) → `{ saved, rejected }`. 201 when anything saved, 400 when everything was rejected. CSRF protected.\\n- `GET /uploads` → `{ uploads }` for the current user.\\n- `GET /uploads/:id` → the bytes, owner-scoped, `Content-Disposition: attachment` (never inline), `Cache-Control: private, no-store`.\\n- `DELETE /uploads/:id` → removes the row; the blob is deleted only when no other row references it. CSRF protected.\\n\\n## Validation and storage\\n\\n- Type is decided by sniffing magic bytes, never the client filename or `Content-Type`. Allowlist: PNG, JPEG, GIF, WebP, PDF. Extend `ALLOWED_TYPES` in `src/lib/uploads.ts`; every entry must carry a byte signature.\\n- Limits live in `src/config/uploads.yaml` (bundled at build): `max_file_mib` (default 5), `max_files_per_request` (5), `user_quota_mib` (100), `rate_limit_per_15min` (30). `src/lib/uploads.ts` validates them at startup and refuses to boot on a bad value. Edit the YAML and re-deploy to change a limit. The oversize and quota checks run before each file is buffered.\\n- Type sniffing matches leading magic bytes only, so a crafted polyglot could carry a valid header. That is why serving forces `attachment` + `nosniff` and never renders inline; do not weaken that (see below). Re-encode images if you need stronger guarantees.\\n- On-disk name is `sha256(content).<sniffed-ext>`, so it can never contain a path separator or traversal segment, and identical bytes are stored once. The original filename is kept as display metadata only, sanitized.\\n- Bytes live under `<db-dir>/uploads` (derived from `DB_PATH`, so the container's `/data` volume); metadata is the `uploads` table.\\n- `resolveStored` rejects any name that is not a bare content-addressed name or that would escape the uploads directory; serving reads only through it.\\n\\n## Request size\\n\\nInstalling uploads raises `maxRequestBodySize` in `src/server.ts` to a fixed 55 MiB. That is the hard server ceiling and is generous headroom over the default limits; if you raise `max_file_mib` x `max_files_per_request` above ~55 MiB in the config, raise this server value to match or the server rejects the body first. Removal restores 1 MiB. Because that ceiling admits large bodies, the auth extension caps its own JSON routes by an actual-bytes read independently; keep that guard on any small-body route you add.\\n\\n## Serving untrusted files\\n\\nDownloads are forced (`attachment`) and owner-scoped by default. Do not switch to inline rendering for user-supplied files without a strict `Content-Security-Policy` and a separate origin; an inline HTML or SVG upload is stored XSS otherwise.\\n\\n## Removal\\n\\n`bun run shibumi remove uploads` deletes the code and reverses the edits (including the `maxRequestBodySize` bump). Remove `uploads` before removing `auth`. The `uploads` table and stored files are never touched by tooling:\\n\\n```sql\\nDROP TABLE uploads;\\n```\\nThen clear `<db-dir>/uploads` if you no longer need the files.\\n\",\"rootSection\":\"## Uploads extension\\n\\nInstalled by `bun run shibumi add uploads` (needs the auth extension); full guide in `agents/uploads.md`.\\n\\n- Routes under `/uploads` (POST upload, GET list, GET :id download, DELETE :id) live in `src/routes/uploads.ts`; validation, storage, and serving in `src/lib/uploads.ts`. Every route requires a session; CSRF covers mutations.\\n- File type is decided by sniffing magic bytes (png/jpeg/gif/webp/pdf), never the client filename or Content-Type. Limits in src/config/uploads.yaml (5 MiB/file, 5/request, 100 MiB/user, 30/15min by default), validated at startup.\\n- Bytes are stored content-addressed (sha256 + sniffed extension) under `<db-dir>/uploads` (the `/data` volume in the container); metadata is the `uploads` table in app.db. On-disk names can never contain a path separator.\\n- Serving is owner-scoped and forces `Content-Disposition: attachment`; uploads are never rendered inline.\\n- The server's `maxRequestBodySize` is raised to 55 MiB by this extension; removal restores 1 MiB.\\n- Removal deletes code and reverses edits; the `uploads` table and stored files stay. Drop/clear them manually.\",\"removeNote\":\"The uploads table stays in app.db and stored files stay under <db-dir>/uploads wherever they were written. Drop the table (DROP TABLE uploads;) and clear the directory manually if unwanted.\"}]";
58
58
  // --- shibumi:extensions:end ---
59
59
 
60
60
  export const EXTENSIONS: ExtensionBundle[] = JSON.parse(EXTENSIONS_JSON);
@@ -826,6 +826,6 @@ if (import.meta.main) {
826
826
  success: (line) => log.success(line),
827
827
  };
828
828
  const code = await runCli(process.argv.slice(2), process.cwd(), io, true);
829
- outro(code === 0 ? `Docs: ${accent("https://shibumistack.dev/extensions")}` : "Stopped; see the messages above.");
829
+ outro(code === 0 ? `Docs: ${accent("https://shibumistack.dev/docs/cli/extensions")}` : "Stopped; see the messages above.");
830
830
  process.exit(code);
831
831
  }
@@ -2,6 +2,12 @@
2
2
  Edit freely; this file is yours. Tokens: --paper --ink --muted --line
3
3
  --accent, type scale --text-xs..--text-2xl, --font-sans/serif/mono. */
4
4
 
5
+ /* The vendored shibumi.css sets a kozo paper background for the sites; apps
6
+ stay flat, and this override also stops the browser fetching the image. */
7
+ body {
8
+ background-image: none;
9
+ }
10
+
5
11
  .scaffold {
6
12
  max-width: 40.625rem;
7
13
  margin: 0 auto;
@@ -60,19 +60,23 @@ body {
60
60
  background: var(--bg);
61
61
  }
62
62
 
63
- h1, h2, h3 {
64
- font-family: var(--font-serif);
63
+ /* Kozo paper texture, light theme only; dark stays flat ink. The image lives
64
+ next to this stylesheet in every consumer, hence the relative URL. */
65
+ body {
66
+ background-image: url("kozo.webp");
67
+ background-size: cover;
68
+ background-position: center;
69
+ background-attachment: fixed;
70
+ background-repeat: no-repeat;
65
71
  }
72
+ @media (prefers-color-scheme: dark) {
73
+ body { background-image: none; }
74
+ }
75
+ [data-theme="light"] body { background-image: url("kozo.webp"); }
76
+ [data-theme="dark"] body { background-image: none; }
66
77
 
67
- /* theme-independent noise texture, copied from shibumi-server */
68
- body::before {
69
- content: "";
70
- position: fixed;
71
- inset: 0;
72
- z-index: -1;
73
- pointer-events: none;
74
- opacity: 0.33;
75
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.88' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E");
78
+ h1, h2, h3 {
79
+ font-family: var(--font-serif);
76
80
  }
77
81
 
78
82
  a { color: inherit; }
@@ -123,9 +127,9 @@ main a:not(.btn):not(.button):focus-visible {
123
127
  width: 100vw;
124
128
  height: 100%;
125
129
  transform: translateX(-50%);
126
- background: light-dark(rgb(245 240 228 / 78%), rgb(27 19 15 / 72%));
127
- backdrop-filter: blur(1.125rem);
128
- -webkit-backdrop-filter: blur(1.125rem);
130
+ background: light-dark(rgb(245 240 228 / 0%), rgb(27 19 15 / 72%));
131
+ backdrop-filter: blur(1.25rem);
132
+ -webkit-backdrop-filter: blur(1.25rem);
129
133
  }
130
134
 
131
135
  .mark {