From dcf24177c2ac5981562453932a3bdcc1a070cfe6 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 15 Jul 2026 10:25:04 -0400 Subject: [PATCH 01/41] =?UTF-8?q?docs(spec):=20forum=20design=20=E2=80=94?= =?UTF-8?q?=20old-school=20boards,=20modernized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XenForo-style categories/boards/threads built in-repo (MIT), magic-link identity (first accounts on the site), BBCode subset, reactions, unread markers, generated avatars, standard moderation kit. Server-first: plain forms + server actions, all three experience modes. Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-15-forum-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-forum-design.md diff --git a/docs/superpowers/specs/2026-07-15-forum-design.md b/docs/superpowers/specs/2026-07-15-forum-design.md new file mode 100644 index 0000000..4d31cbb --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-forum-design.md @@ -0,0 +1,257 @@ +# The Forum — old-school boards, modernized + +_Date: 2026-07-15 · Status: design (awaiting review) · First feature with user accounts; sets the identity pattern Phase 4 (fan tracking) will reuse._ + +## Goal + +Give Goose Index a classic forum — the XenForo/phpBB shape everyone remembers: +**categories → boards → threads → flat replies**, with member profiles, quoting, +reactions, and unread markers. Modernized in the ways that matter (passwordless +auth, safe rendering, fast server-rendered pages) and deliberately old-school in +the ways that are the point (plain forms, real pagination, works with JS off). + +Built **in-repo** (MIT — the open-source requirement), on the existing +Vercel + Neon + Drizzle stack. No new hosting, no second system. XenForo itself +is commercial/closed-source, so we recreate the experience, not the software. + +## Decisions locked during brainstorm + +| Question | Decision | +|---|---| +| Shape | XenForo-style category boards (not show-threads, not a single lounge) | +| Open source | Build in-repo under the project's MIT license | +| Identity | Email **magic link** — username + email, no passwords | +| Moderation | Standard kit: reports, delete/edit, lock/pin, bans, throttles, honeypot | +| Architecture | **Server-first**: server components + plain HTML forms + server actions; JS "sprinkles" only where they earn it | +| v1 trimmings | Quoting + BBCode, reactions (Like/Honk), unread markers, generated avatars, signatures | + +## Routes & pages + +| Route | What it is | +|---|---| +| `/forum` | Board index: categories with their boards; per board: description, thread/post counts, last post (thread title, author, time). Footer line: "Members online in the last 15 minutes" (from `last_seen_at`). | +| `/forum/[board]` | Paginated thread list (25/page): pinned first (📌), then by `last_post_at` desc. Row: title, author, reply count, last post by/at, lock marker, bold-when-unread. "Post New Thread" button. | +| `/forum/[board]/new` | New-thread composer: title (3–120 chars) + body. | +| `/forum/threads/[key]` | The thread. `key` is `{id}` or `{id}-{slug}`; non-canonical keys 301 to the canonical `{id}-{slug}`. Flat replies, 20/page, oldest-first. Composer at the bottom (locked threads: notice instead — admins may still post). `?page=unread` jumps to first unread post. | +| `/forum/members/[username]` | Public profile: avatar, join date, post count, signature, 10 most recent posts. Never shows email. | +| `/forum/join` `/forum/login` `/forum/verify` `/forum/logout` `/forum/settings` | Account pages. `verify` is the magic-link landing page. Settings: signature, email change (re-verified via magic link). | +| `/forum/admin` | Admin only: open reports queue, recent members, ban controls. | + +**Experience modes.** Every forum page renders in all three modes via the same +per-mode component pattern the rest of the site uses: + +- **3.0 (fancy)** — the site's immersive design language. +- **2.0 (functional)** — the glossy dense skin; the most natively-XenForo mode. +- **1.0 (minimal)** — semantic HTML + JSON-LD (`DiscussionForumPosting`). Because + everything is plain forms, 1.0 is fully *operational*, not just readable. + +**Rendering note:** all forum pages are dynamic (they read the session cookie); +no ISR/caching work in v1. + +## Data model + +New Drizzle tables (integer identity PKs, migrations via `drizzle-kit`). +Identity tables are site-level (no `forum_` prefix) — Phase 4 reuses them. + +### Identity (site-level) + +- **`users`** — `id`, `username` (display case), `username_lower` (unique), + `email_lower` (unique), `role` (`member` | `admin`), `signature` (text, raw + BBCode, ≤200 chars), `post_count` (denormalized), `joined_at`, + `last_seen_at` (updated on authed requests, throttled to ≥5 min), + `mark_all_read_at` (for board-wide mark-read), `banned_at`, `banned_reason`. + Username: 3–20 chars, `[A-Za-z0-9_-]`, unique case-insensitively, immutable in v1. +- **`sessions`** — `token_hash` (PK, sha256 of a 32-byte random token), + `user_id`, `created_at`, `expires_at` (90 days, slid forward when the + remaining life drops below 83 days), `last_used_at`. Cookie: `ga_session`, + httpOnly, secure, `samesite=lax`, path `/`. +- **`login_tokens`** — `token_hash` (PK), `purpose` (`signup` | `login` | + `email-change`), `email_lower`, `username` (signup only), `user_id` (login / + email-change), `created_at`, `expires_at` (15 min), `used_at` (single-use). + +### Forum + +- **`forum_categories`** — `id`, `title`, `position`. +- **`forum_boards`** — `id`, `category_id`, `slug` (unique), `title`, + `description`, `position`, `thread_count`, `post_count`, `last_post_id` + (denormalized). +- **`forum_threads`** — `id`, `board_id`, `author_id`, `title`, `slug` + (from title, reusing the `db/slugs.ts` conventions), `pinned`, `locked`, + `reply_count`, `last_post_id`, `last_post_at`, `created_at`. + Index: `(board_id, pinned desc, last_post_at desc)`. +- **`forum_posts`** — `id`, `thread_id`, `author_id`, `body` (raw BBCode + source, ≤20,000 chars), `created_at`, `edited_at`, `edited_by_id`, + `deleted_at`, `deleted_by_id`. Soft delete: the row stays; readers see a + "removed by a moderator" tombstone; admins see the content. Tombstones keep + their page slot, so `reply_count`/`post_count`/`last_post_*` include them — + deleting a post never renumbers pages or rewinds counters. + Index: `(thread_id, id)`. +- **`forum_reactions`** — `post_id`, `user_id`, `kind` (`like` | `honk`), + `created_at`. PK `(post_id, user_id)` — one reaction per member per post; + re-reacting with the other kind replaces, same kind removes (toggle). +- **`forum_reports`** — `id`, `post_id`, `reporter_id`, `reason` (≤500 chars), + `created_at`, `resolved_at`, `resolved_by_id`. +- **`forum_read_markers`** — `user_id`, `thread_id`, `last_read_post_id`, + `updated_at`. PK `(user_id, thread_id)`. Upserted when a signed-in member + views a thread page (high-water mark: only moves forward). + +**Unread logic** (signed-in only): a thread is unread iff +`last_post_at > user.mark_all_read_at` **and** (no marker **or** +`marker.last_read_post_id < thread.last_post_id`). "Jump to first unread" = +first post with `id > marker.last_read_post_id`. Signed-out visitors get no +unread affordances. + +**Denormalized counters** (`post_count`s, `reply_count`, `last_post_*`) are +maintained in the same transaction as the write that changes them, and a +`lib/verify/` check recomputes them against ground truth (matching the +project's existing verify pattern). + +### Seed content + +Categories/boards are seeded by migration (final wording is Tim's call, easy to edit): + +- **The Music** — Tour Talk · Setlists & Stats · Tapes & Media +- **Community** — Introductions · Off Topic · Site Feedback + +## Auth — magic links + +**Join:** username + email + hidden honeypot field + signed form-issued-at +timestamp. On submit: validate username/email availability, create a `signup` +login token, email the link. Page always says "check your email" (no account +enumeration). Clicking `/forum/verify?token=…`: if valid/unused/unexpired → +create user + session, cookie set, redirect to `/forum`. Race on username +(taken between form and click) → friendly "pick a new username" form that +reuses the verified email. + +**Login:** email only. If it matches a user, send a `login` token; the page +response is identical either way. Verify → session. + +**Email sending:** Resend HTTP API via plain `fetch` in `lib/auth/email.ts` — +**no new npm dependency**. Env: `RESEND_API_KEY`, `AUTH_EMAIL_FROM` +(e.g. `Goose Index `; domain verified in Resend — gets a +DEPLOY.md runbook entry). Local dev without the key: the magic link is printed +to the server console instead of sent. + +**Security properties:** tokens and sessions stored only as sha256 hashes; +tokens single-use, 15-minute expiry; no passwords; PII is one email address; +logout deletes the session row and clears the cookie. + +## BBCode + +Supported in v1: `[b] [i] [u] [s] [url=…] [quote] [quote=name] [code]`, plus +newlines → paragraphs/breaks and bare URLs auto-linked. No `[img]` (deferred — +hotlink moderation trap). + +- Hand-rolled tokenizer/parser in `lib/forum/bbcode.ts` → AST → React elements. + **Never `dangerouslySetInnerHTML`**; all text is React-escaped by default. +- Unknown/malformed tags render as literal text (never dropped silently). +- `[url]` allows only `http(s)` schemes; rendered links get + `rel="nofollow ugc"`. `[code]` contents are verbatim (no nested parsing). + `[quote]` nesting capped at 3 levels; deeper renders literally. +- Signatures allow the inline subset only (`[b] [i] [url]`). +- Tested against an XSS corpus (script/`javascript:`/data: URLs, broken nesting, + entity tricks — the existing `decodeEntities` lesson applies). + +## The trimmings + +- **Quoting** — every post has a Quote control. Sprinkle: inserts + `[quote=name]…[/quote]` into the composer textarea. No JS: the control is a + link to the thread's last page with `?quote=`, which the server + renders into the composer's initial value. +- **Reactions** — Like 👍 and Honk 🪿 under each post with counts. Plain form + POST to a server action (works JS-off); sprinkle upgrades to no-reload. + Signed-in only; not on deleted posts; own posts can't be reacted to by + their author. +- **Unread markers** — bold unread thread rows, "jump to first unread", and a + "Mark forums read" control (sets `mark_all_read_at = now()`). +- **Avatars** — deterministic goose-flavored SVG identicon from + `hash(username_lower)` (palette drawn from the site's theme tokens), + rendered inline. Zero storage, zero upload moderation. Uploads deferred. +- **Signatures** — ≤200 chars, inline BBCode, shown under each post + (per author, once per page in 1.0 to keep the document clean). +- **Who's online** — "Members online in the last 15 minutes: …" line on + `/forum` from `last_seen_at`. Cheap, deeply old-school. + +## Moderation & spam + +**Roles:** `member` and `admin`. Admins are promoted by script +(`npm run make-admin -- `), not by UI. Admin powers: soft-delete and +edit any post (edits show "edited by"), lock/pin threads, ban/unban members, +resolve reports, post in locked threads. + +**Reports:** a Report control on every post (signed-in only) → reason form → +`forum_reports` row. `/forum/admin` lists open reports with post context and +one-click resolve/delete-post actions. + +**Bans:** banned members can read everything, and see their ban reason on any +write attempt; all writes (posts, threads, reactions, reports) are refused. + +**Spam defense (all server-enforced):** + +| Control | Rule | +|---|---| +| Signup honeypot | Hidden field must be empty **and** form submitted ≥3s after issue (signed timestamp) | +| Magic-link issuance | ≤3/hour per email, ≤10/hour per IP (`x-forwarded-for` on Vercel) | +| Posting throttle | ≥30s between posts per member | +| New accounts (<24h) | ≥60s between posts, ≤30 posts/day, ≤3 new threads/day, ≤2 links per post | +| Established accounts | ≤10 new threads/day | + +Throttles are computed by querying the member's recent rows — no extra tables. + +## Error handling + +Server actions validate and return structured field errors rendered inline by +the form (no client validation needed). Notable cases: expired/used token → +"link expired, request a new one" with a resend form; username taken at verify +time → re-pick form; posting to a locked/deleted thread → notice; banned → +reason shown; throttled → "slow down" with the wait time; oversize body/title → +inline error preserving the draft text. + +## SEO & privacy + +- Boards and threads are indexable; `/forum` and board URLs join the sitemap + (threads stay out in v1 — churn). +- `noindex`: member profiles and join/login/verify/settings/admin. Paginated + thread pages stay indexable and self-canonical. +- Emails never appear in any HTML, feed, or JSON-LD. Usernames are the only + public identity. + +## Testing + +Vitest, same conventions as the repo (pglite via `db/testing.ts` for DB tests; +component tests for rendering): + +- **BBCode:** parse/render round-trips, malformed input, nesting caps, the XSS + corpus, scheme filtering. +- **Auth:** token lifecycle (issue → verify → single-use → expiry), session + slide, enumeration-safe responses, honeypot/time-trap rejection. +- **Forum queries** (`lib/queries/forum.ts` — pages contain no SQL): pagination + windows, pinned ordering, unread math, counter maintenance under + concurrent-ish writes. +- **Throttles:** each rule in the table above, boundary cases. +- **Moderation:** soft-delete tombstones, ban gating, report lifecycle. +- **Components:** post rendering (quotes, signatures, tombstones), thread row + states (unread/pinned/locked) across the three modes. + +## Build order + +- **A — Identity:** `users`/`sessions`/`login_tokens`, magic-link flows, email + sender, settings page, make-admin script. _Land: you can join, log in, log out._ +- **B — Core forum:** categories/boards/threads/posts tables + seeds, board + index, thread list, thread view, composers, BBCode, pagination. _Land: a + working forum._ +- **C — Trimmings:** quoting, reactions, unread markers, avatars, signatures, + who's-online. _Land: it feels like XenForo._ +- **D — Moderation kit:** reports, admin queue, bans, throttles, honeypot. + _Land: safe to announce._ + +Each phase merges green (tests + typecheck + build) before the next starts. +D's throttle/honeypot work ships **before** any public announcement of the forum. + +## Deferred (explicitly out of v1) + +`[img]` and image uploads (needs Vercel Blob + moderation) · avatar uploads · +watched threads & notifications · private messages · forum search (fold into +site search later) · post edit history · member titles/ranks · thread view +counts · RSS feeds · "show discussion" threads auto-linked from show pages +(natural v2 — the machinery makes this a one-liner board + auto-thread). From 5615a48d971f25d195474c3df9a7e064528b21fe Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 15 Jul 2026 11:22:33 -0400 Subject: [PATCH 02/41] =?UTF-8?q?docs(plan):=20forum=20implementation=20pl?= =?UTF-8?q?an=20=E2=80=94=2027=20tasks=20across=20phases=20A=E2=80=93D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity (magic links, sessions), core forum (schema, BBCode, pages), trimmings (reactions, unread, avatars, quoting), moderation kit (reports, admin, throttles, honeypot). TDD steps with complete code. Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-07-15-forum.md | 5204 ++++++++++++++++++++ 1 file changed, 5204 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-forum.md diff --git a/docs/superpowers/plans/2026-07-15-forum.md b/docs/superpowers/plans/2026-07-15-forum.md new file mode 100644 index 0000000..6cbca64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-forum.md @@ -0,0 +1,5204 @@ +# The Forum Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A XenForo-style forum at `/forum` — categories → boards → threads → flat replies — with magic-link accounts, BBCode, reactions, unread markers, and a moderation kit, per `docs/superpowers/specs/2026-07-15-forum-design.md`. + +**Architecture:** Server-first Next.js App Router: server components render pages in all three experience modes; every mutation is a plain HTML `
` posting to a server action; small client components only for form state (`useActionState`) and the quote-insert sprinkle. All data access lives in `lib/queries/forum.ts` + `lib/forum/mutations.ts` against the existing Drizzle/Postgres stack. Identity is site-level (`users`/`sessions`/`login_tokens`), passwordless via emailed magic links. + +**Tech Stack:** Next.js 15 (App Router, server actions), React 19 (`useActionState`), Drizzle ORM + postgres-js (prod) / PGlite (tests), Tailwind 4, Vitest, Resend HTTP API via plain `fetch`. + +## Global Constraints + +- **No new npm dependencies.** Resend is called with `fetch`; hashing/HMAC via `node:crypto`. +- **No SQL in pages.** Pages call `lib/queries/forum.ts` (reads) and server actions call `lib/forum/mutations.ts` (writes) only. +- **Every public content page renders in all 3 experience modes** (`fancy`/`functional`/`minimal` via `getExperience()`); forum components take an `experience` prop like `ShowList` does. Noindex utility pages (join/login/verify/settings/new/edit/admin) are plain semantic forms that share one layout across modes — they satisfy 1.0 by construction. +- **Plain forms everywhere** — every mutation must work with JavaScript disabled. +- **Never render an email address** in any HTML, JSON-LD, or feed. +- **Raw BBCode is stored; rendering never uses `dangerouslySetInnerHTML`.** +- **TDD**: write the failing test first for every lib/db unit. Component tests use `renderToStaticMarkup` (house pattern). Commit at the end of every task. +- **Env vars** (all optional in dev): `RESEND_API_KEY`, `AUTH_EMAIL_FROM`, `AUTH_SECRET`. Missing `RESEND_API_KEY` → magic links print to the server console. +- **Repo gotcha:** never run `npm run build` while `npm run dev` is running (clobbers `.next`). +- Tests that hit the DB use `makeTestDb()` (PGlite) + the `vi.mock("@/db/client", …)` redirect pattern from `lib/queries/shows.test.ts`. +- Constants (single source of truth): `POSTS_PER_PAGE = 20`, `THREADS_PER_PAGE = 25`, `TITLE_MIN = 3`, `TITLE_MAX = 120`, `BODY_MAX = 20000`, `SIGNATURE_MAX = 200`, `QUOTE_DEPTH_MAX = 3` in `lib/forum/constants.ts`; `TOKEN_TTL_MS = 15 min`, `SESSION_TTL_MS = 90 days` in `lib/auth/service.ts`. +- All timestamps render as UTC strings formatted in SQL (`YYYY-MM-DD HH24:MI`); no client-side date math (avoids hydration drift). + +## File Structure + +``` +db/schema.ts + users, sessions, loginTokens (Task 1) + + forumCategories/Boards/Threads/Posts (Task 8) + + forumReactions (T19), forumReadMarkers (T20), forumReports (T24) +drizzle/*.sql generated migrations + one hand-written seed (T8) +lib/auth/crypto.ts newToken(), hashToken() (T2) +lib/auth/validate.ts validateUsername(), validateEmail() (T2) +lib/auth/email.ts authOrigin(), sendMagicLink() (T3) +lib/auth/service.ts request/verify/session functions (T4–5) +lib/auth/session.server.ts cookie layer: currentUser(), requireUser() (T6) +lib/auth/formstamp.ts honeypot time-trap stamps (T26) +lib/forum/constants.ts page sizes + length limits (T9) +lib/forum/bbcode.ts parseBBCode/parseBBCodeInline → AST (T9) +lib/forum/bbcode-render.tsx , (T10) +lib/forum/slugs.ts threadSlug() (T11) +lib/forum/mutations.ts createThread/createPost/editPost (T11), toggleReaction (T19), + read markers (T20), moderation + reports (T24–25) +lib/forum/throttle.ts postGate(), threadGate() (T26) +lib/forum/avatar.tsx deterministic SVG identicon (T21) +lib/queries/forum.ts all forum reads (T12+) +lib/jsonld.ts + forumThreadJsonLd() (T15) +app/forum/auth-actions.ts join/login/verify/logout/settings actions (T6–7) +app/forum/actions.ts thread/post/reaction/report/admin actions (T14+) +app/forum/page.tsx board index (T13) +app/forum/[board]/page.tsx thread list (T14) +app/forum/[board]/new/page.tsx new-thread composer (T14) +app/forum/threads/[key]/page.tsx thread view (T15–16) +app/forum/posts/[id]/edit/page.tsx edit own post (T16) +app/forum/members/[username]/page.tsx profile (T17) +app/forum/join|login|verify|settings/page.tsx (T6–7) +app/forum/admin/page.tsx report queue + members (T25) +app/forum/_components/forms.tsx JoinForm/LoginForm/VerifyForm/SettingsForm (T6–7) +app/forum/_components/user-strip.tsx signed-in strip on every forum page (T6) +app/forum/_components/board-index.tsx BoardIndex (3 modes) (T13) +app/forum/_components/thread-list.tsx ThreadTable (3 modes) (T14) +app/forum/_components/pager.tsx shared pagination links (T14) +app/forum/_components/post-card.tsx PostCard (3 modes) (T15+) +app/forum/_components/composer.tsx reply/new-thread form (client) (T14/16) +app/forum/_components/quote-button.tsx quote sprinkle (client) (T22) +app/_components/site-header.tsx + Forum nav link (T13) +app/sitemap.ts + forum URLs (T27) +lib/verify/checks.ts + checkForumCounters() (T27) +scripts/make-admin.ts promote a user by email (T7) +docs/DEPLOY.md, README.md runbook + roadmap updates (T27) +``` + +Design rule used throughout: **form components receive their server action as a prop** (`action={joinAction}`) so component tests can pass a stub and never import `next/headers` transitively. + +--- + +# Phase A — Identity + +_Lands: you can join, log in, log out, edit settings. Working software on its own._ + +### Task 1: Identity schema + migration + +**Files:** +- Modify: `db/schema.ts` (append after `liveSyncState`) +- Create: `drizzle/` migration via `npm run db:generate` +- Test: `db/identity-schema.test.ts` + +**Interfaces:** +- Consumes: existing `pgTable` helpers, `makeTestDb()`. +- Produces: exported Drizzle tables `users`, `sessions`, `loginTokens` with the exact columns below; every later task references these names. + +- [ ] **Step 1: Write the failing test** + +```ts +// db/identity-schema.test.ts +import { describe, it, expect, afterAll } from "vitest"; +import { makeTestDb } from "@/db/testing"; +import { users, sessions, loginTokens } from "@/db/schema"; + +const ctx = await makeTestDb(); +afterAll(() => ctx.close()); + +describe("identity schema", () => { + it("round-trips a user, a session, and a login token", async () => { + const [u] = await ctx.db.insert(users).values({ + username: "HonkFan", usernameLower: "honkfan", emailLower: "honk@example.com", + }).returning({ id: users.id, role: users.role, postCount: users.postCount }); + expect(u.id).toBeGreaterThan(0); + expect(u.role).toBe("member"); + expect(u.postCount).toBe(0); + + await ctx.db.insert(sessions).values({ + tokenHash: "abc", userId: u.id, expiresAt: new Date(Date.now() + 1000), + }); + await ctx.db.insert(loginTokens).values({ + tokenHash: "def", purpose: "login", emailLower: "honk@example.com", userId: u.id, + expiresAt: new Date(Date.now() + 1000), + }); + const s = await ctx.db.select().from(sessions); + expect(s).toHaveLength(1); + }); + + it("rejects duplicate username_lower and email_lower", async () => { + await expect(ctx.db.insert(users).values({ + username: "HONKFAN", usernameLower: "honkfan", emailLower: "other@example.com", + })).rejects.toThrow(); + await expect(ctx.db.insert(users).values({ + username: "Other", usernameLower: "other", emailLower: "honk@example.com", + })).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run db/identity-schema.test.ts` +Expected: FAIL — `users` has no export / relation "users" does not exist. + +- [ ] **Step 3: Add the tables to `db/schema.ts`** + +Append after `liveSyncState` (before the `AppDb` type): + +```ts +// ---- Identity (site-level: the forum uses it now; Phase 4 fan-tracking will too) ---- + +export const users = pgTable("users", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + username: text("username").notNull(), // display case + usernameLower: text("username_lower").notNull().unique(), + emailLower: text("email_lower").notNull().unique(), // the only PII we hold + role: text("role").notNull().default("member"), // member | admin + signature: text("signature"), + postCount: integer("post_count").notNull().default(0), // denormalized; verified by lib/verify + joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + markAllReadAt: timestamp("mark_all_read_at", { withTimezone: true }), + bannedAt: timestamp("banned_at", { withTimezone: true }), + bannedReason: text("banned_reason"), +}); + +// Stored hashed (sha256 of the cookie token) — a DB leak exposes no usable session. +export const sessions = pgTable("sessions", { + tokenHash: text("token_hash").primaryKey(), + userId: integer("user_id").notNull().references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), +}, (t) => ({ userIdx: index("sessions_user_idx").on(t.userId) })); + +// Magic-link tokens, also stored hashed; single-use, 15-minute expiry. +export const loginTokens = pgTable("login_tokens", { + tokenHash: text("token_hash").primaryKey(), + purpose: text("purpose").notNull(), // signup | login | email-change + emailLower: text("email_lower").notNull(), + username: text("username"), // signup: the requested display username + userId: integer("user_id").references(() => users.id), // login / email-change + ip: text("ip"), // for issuance rate limits (enforced in Phase D) + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), +}, (t) => ({ emailIdx: index("login_tokens_email_idx").on(t.emailLower) })); +``` + +- [ ] **Step 4: Generate the migration** + +Run: `npm run db:generate` +Expected: a new file `drizzle/00NN_*.sql` containing `CREATE TABLE "users" …` etc. Inspect it — it must create exactly three tables, two unique constraints, two indexes. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run db/identity-schema.test.ts` → PASS. Then `npm test` (whole suite) and `npm run typecheck` → clean. + +- [ ] **Step 6: Commit** + +```bash +git add db/schema.ts db/identity-schema.test.ts drizzle +git commit -m "feat(auth): users, sessions, login_tokens tables" +``` + +### Task 2: Token crypto + username/email validation + +**Files:** +- Create: `lib/auth/crypto.ts`, `lib/auth/validate.ts` +- Test: `lib/auth/crypto.test.ts`, `lib/auth/validate.test.ts` + +**Interfaces:** +- Consumes: `node:crypto` only. +- Produces (used by Tasks 4–6): + - `newToken(): string` — 32 random bytes, base64url (43 chars, no padding). + - `hashToken(token: string): string` — sha256 hex. + - `validateUsername(raw: string): { ok: true; username: string } | { ok: false; error: string }` — trimmed; must match `/^[A-Za-z0-9_-]{3,20}$/`. + - `validateEmail(raw: string): { ok: true; emailLower: string } | { ok: false; error: string }` — trimmed, lowercased; shape check only (`x@y.z`, one `@`, ≤254 chars). + +- [ ] **Step 1: Write the failing tests** + +```ts +// lib/auth/crypto.test.ts +import { describe, it, expect } from "vitest"; +import { newToken, hashToken } from "./crypto"; + +describe("auth crypto", () => { + it("generates url-safe 43-char tokens, unique per call", () => { + const a = newToken(), b = newToken(); + expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(a).not.toBe(b); + }); + it("hashes deterministically to sha256 hex", () => { + expect(hashToken("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + expect(hashToken("abc")).toBe(hashToken("abc")); + }); +}); +``` + +```ts +// lib/auth/validate.test.ts +import { describe, it, expect } from "vitest"; +import { validateUsername, validateEmail } from "./validate"; + +describe("validateUsername", () => { + it("accepts 3–20 chars of [A-Za-z0-9_-], preserving case", () => { + expect(validateUsername(" HonkFan_22 ")).toEqual({ ok: true, username: "HonkFan_22" }); + }); + it.each(["ab", "a".repeat(21), "sp ace", "émile", "dot.name", ""])("rejects %j", (bad) => { + expect(validateUsername(bad).ok).toBe(false); + }); +}); + +describe("validateEmail", () => { + it("lowercases and trims", () => { + expect(validateEmail(" Tim@Example.COM ")).toEqual({ ok: true, emailLower: "tim@example.com" }); + }); + it.each(["no-at", "two@@ats", "@start.com", "end@", "a@b", ""])("rejects %j", (bad) => { + expect(validateEmail(bad).ok).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run lib/auth` → FAIL (modules don't exist). + +- [ ] **Step 3: Implement** + +```ts +// lib/auth/crypto.ts +import { randomBytes, createHash } from "node:crypto"; + +/** 32 random bytes, base64url — the raw secret that goes in links/cookies. */ +export function newToken(): string { + return randomBytes(32).toString("base64url"); +} + +/** sha256 hex — the only form ever stored in the database. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} +``` + +```ts +// lib/auth/validate.ts +const USERNAME_RE = /^[A-Za-z0-9_-]{3,20}$/; +// Shape check only — the magic link is the real verification. +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function validateUsername(raw: string): { ok: true; username: string } | { ok: false; error: string } { + const username = raw.trim(); + if (!USERNAME_RE.test(username)) { + return { ok: false, error: "Usernames are 3–20 characters: letters, numbers, - and _." }; + } + return { ok: true, username }; +} + +export function validateEmail(raw: string): { ok: true; emailLower: string } | { ok: false; error: string } { + const emailLower = raw.trim().toLowerCase(); + if (emailLower.length > 254 || !EMAIL_RE.test(emailLower)) { + return { ok: false, error: "That doesn't look like an email address." }; + } + return { ok: true, emailLower }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run lib/auth` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/auth +git commit -m "feat(auth): token crypto + username/email validation" +``` + +### Task 3: Magic-link email sender + +**Files:** +- Create: `lib/auth/email.ts` +- Test: `lib/auth/email.test.ts` + +**Interfaces:** +- Consumes: `SITE_URL` from `lib/site.ts`. +- Produces (used by Tasks 6–7): + - `authOrigin(): string` — `SITE_URL` when `process.env.VERCEL` is set, else `http://localhost:3000`. + - `sendMagicLink(msg: { to: string; url: string; kind: "signup" | "login" | "email-change" }, fetchImpl?: typeof fetch): Promise` — POSTs to Resend when `RESEND_API_KEY` is set; otherwise logs the URL to the console (dev flow). Throws on a non-2xx Resend response. + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/auth/email.test.ts +import { describe, it, expect, vi, afterEach } from "vitest"; +import { sendMagicLink, authOrigin } from "./email"; + +afterEach(() => { delete process.env.RESEND_API_KEY; delete process.env.VERCEL; }); + +describe("sendMagicLink", () => { + it("without RESEND_API_KEY logs the link instead of fetching", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const fetchImpl = vi.fn(); + await sendMagicLink({ to: "a@b.co", url: "http://localhost:3000/forum/verify?token=T", kind: "login" }, fetchImpl as any); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join(" ")).toContain("/forum/verify?token=T"); + log.mockRestore(); + }); + + it("with RESEND_API_KEY posts to Resend with auth header", async () => { + process.env.RESEND_API_KEY = "re_test"; + process.env.AUTH_EMAIL_FROM = "Goose Index "; + const fetchImpl = vi.fn().mockResolvedValue({ ok: true }); + await sendMagicLink({ to: "a@b.co", url: "https://x/verify?token=T", kind: "signup" }, fetchImpl as any); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe("https://api.resend.com/emails"); + expect(init.headers.Authorization).toBe("Bearer re_test"); + const body = JSON.parse(init.body); + expect(body.to).toEqual(["a@b.co"]); + expect(body.text).toContain("https://x/verify?token=T"); + }); + + it("throws on a non-2xx response", async () => { + process.env.RESEND_API_KEY = "re_test"; + const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 422, text: async () => "nope" }); + await expect(sendMagicLink({ to: "a@b.co", url: "u", kind: "login" }, fetchImpl as any)).rejects.toThrow(/422/); + }); +}); + +describe("authOrigin", () => { + it("is localhost in dev and SITE_URL on Vercel", () => { + expect(authOrigin()).toBe("http://localhost:3000"); + process.env.VERCEL = "1"; + expect(authOrigin()).toBe("https://www.gooseindex.com"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run lib/auth/email.test.ts` → FAIL (module doesn't exist). + +- [ ] **Step 3: Implement** + +```ts +// lib/auth/email.ts +import { SITE_URL } from "@/lib/site"; + +export function authOrigin(): string { + return process.env.VERCEL ? SITE_URL : "http://localhost:3000"; +} + +const SUBJECTS = { + signup: "Finish joining the Goose Index forum", + login: "Your Goose Index sign-in link", + "email-change": "Confirm your new email for Goose Index", +} as const; + +export type MagicLinkMessage = { to: string; url: string; kind: keyof typeof SUBJECTS }; + +/** Sends via the Resend HTTP API; with no RESEND_API_KEY (local dev) prints the link. */ +export async function sendMagicLink(msg: MagicLinkMessage, fetchImpl: typeof fetch = fetch): Promise { + const key = process.env.RESEND_API_KEY; + if (!key) { + console.log(`[auth] magic link for ${msg.to} (${msg.kind}): ${msg.url}`); + return; + } + const res = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: process.env.AUTH_EMAIL_FROM ?? "Goose Index ", + to: [msg.to], + subject: SUBJECTS[msg.kind], + text: `Click to continue:\n\n${msg.url}\n\nThis link works once and expires in 15 minutes. If you didn't request it, ignore this email.`, + }), + }); + if (!res.ok) throw new Error(`Resend responded ${res.status}: ${await res.text()}`); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run lib/auth/email.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/auth/email.ts lib/auth/email.test.ts +git commit -m "feat(auth): magic-link email sender (Resend via fetch, console fallback)" +``` + +### Task 4: Auth service — requesting magic links + +**Files:** +- Create: `lib/auth/service.ts` +- Test: `lib/auth/service.test.ts` + +**Interfaces:** +- Consumes: `newToken`/`hashToken` (T2), `validateUsername`/`validateEmail` (T2), tables from T1, global `db` from `@/db/client`. +- Produces (used by T5–7, actions in T6): + - `TOKEN_TTL_MS = 15 * 60_000`, `SESSION_TTL_MS = 90 * 24 * 3_600_000`, `SLIDE_AFTER_MS = 7 * 24 * 3_600_000`. + - `type SessionUser = { id: number; username: string; role: "member" | "admin"; signature: string | null; postCount: number; joinedAt: Date; markAllReadAt: Date | null; bannedAt: Date | null; bannedReason: string | null }`. + - `requestSignup(usernameRaw: string, emailRaw: string, ip: string | null): Promise<{ status: "sent"; kind: "signup" | "login"; token: string; emailLower: string } | { status: "error"; error: string }>` — if the email already has an account, silently issues a **login** token instead (kind `"login"`); a taken username is a visible error (usernames are public — no enumeration concern). + - `requestLogin(emailRaw: string, ip: string | null): Promise<{ status: "sent"; token: string; emailLower: string } | { status: "silent" } | { status: "error"; error: string }>` — `"silent"` = no such account; the caller shows the same success page and sends nothing. + - `requestEmailChange(userId: number, emailRaw: string): Promise<{ status: "sent"; token: string; emailLower: string } | { status: "error"; error: string }>` — error if the email belongs to another account. + +The returned `token` is the RAW token (only its hash is stored); the caller builds `${authOrigin()}/forum/verify?token=${token}` and calls `sendMagicLink`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// lib/auth/service.test.ts +import { describe, it, expect, afterAll, vi } from "vitest"; +import { makeTestDb } from "@/db/testing"; +import { loginTokens, users } from "@/db/schema"; +import { hashToken } from "./crypto"; + +let _testDb: Awaited>["db"] | null = null; +vi.mock("@/db/client", () => ({ + db: new Proxy({} as Record, { + get(_t, prop) { + if (!_testDb) throw new Error("Test db not initialised"); + const real = _testDb as unknown as Record; + const val = real[prop]; + return typeof val === "function" ? val.bind(real) : val; + }, + }), +})); +const ctx = await makeTestDb(); +_testDb = ctx.db; +afterAll(() => ctx.close()); + +const { requestSignup, requestLogin, requestEmailChange } = await import("./service"); + +describe("requestSignup", () => { + it("issues a hashed single-purpose signup token", async () => { + const r = await requestSignup("HonkFan", "honk@example.com", "1.2.3.4"); + expect(r.status).toBe("sent"); + if (r.status !== "sent") return; + expect(r.kind).toBe("signup"); + const rows = await ctx.db.select().from(loginTokens); + expect(rows).toHaveLength(1); + expect(rows[0].tokenHash).toBe(hashToken(r.token)); + expect(rows[0].tokenHash).not.toBe(r.token); // raw token never stored + expect(rows[0].purpose).toBe("signup"); + expect(rows[0].username).toBe("HonkFan"); + expect(rows[0].ip).toBe("1.2.3.4"); + }); + + it("rejects an invalid username visibly", async () => { + const r = await requestSignup("x", "a@b.co", null); + expect(r).toMatchObject({ status: "error" }); + }); + + it("rejects a taken username visibly (case-insensitive)", async () => { + await ctx.db.insert(users).values({ username: "Taken", usernameLower: "taken", emailLower: "t@x.co" }); + const r = await requestSignup("TAKEN", "new@x.co", null); + expect(r).toMatchObject({ status: "error" }); + }); + + it("switches to a login token when the email already has an account", async () => { + const r = await requestSignup("Brandnew", "t@x.co", null); // t@x.co registered above + expect(r).toMatchObject({ status: "sent", kind: "login" }); + }); +}); + +describe("requestLogin", () => { + it("is silent for unknown emails", async () => { + expect(await requestLogin("ghost@x.co", null)).toEqual({ status: "silent" }); + }); + it("issues a login token for a known email", async () => { + const r = await requestLogin("t@x.co", null); + expect(r.status).toBe("sent"); + }); +}); + +describe("requestEmailChange", () => { + it("errors when the email belongs to another account", async () => { + const [u] = await ctx.db.insert(users).values({ username: "Second", usernameLower: "second", emailLower: "second@x.co" }).returning({ id: users.id }); + const r = await requestEmailChange(u.id, "t@x.co"); + expect(r.status).toBe("error"); + }); + it("issues an email-change token otherwise", async () => { + const [u] = await ctx.db.select({ id: users.id }).from(users); + const r = await requestEmailChange(u.id, "fresh@x.co"); + expect(r.status).toBe("sent"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run lib/auth/service.test.ts` → FAIL (`./service` doesn't exist). + +- [ ] **Step 3: Implement the request functions** + +```ts +// lib/auth/service.ts +import { db } from "@/db/client"; +import { users, sessions, loginTokens } from "@/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { newToken, hashToken } from "./crypto"; +import { validateUsername, validateEmail } from "./validate"; + +export const TOKEN_TTL_MS = 15 * 60_000; +export const SESSION_TTL_MS = 90 * 24 * 3_600_000; +export const SLIDE_AFTER_MS = 7 * 24 * 3_600_000; + +export type SessionUser = { + id: number; username: string; role: "member" | "admin"; signature: string | null; + postCount: number; joinedAt: Date; markAllReadAt: Date | null; + bannedAt: Date | null; bannedReason: string | null; +}; + +type Sent = { status: "sent"; kind: "signup" | "login"; token: string; emailLower: string }; +type Err = { status: "error"; error: string }; + +async function issueToken(fields: { + purpose: "signup" | "login" | "email-change"; emailLower: string; + username?: string; userId?: number; ip?: string | null; +}): Promise { + const token = newToken(); + await db.insert(loginTokens).values({ + tokenHash: hashToken(token), purpose: fields.purpose, emailLower: fields.emailLower, + username: fields.username ?? null, userId: fields.userId ?? null, ip: fields.ip ?? null, + expiresAt: new Date(Date.now() + TOKEN_TTL_MS), + }); + return token; +} + +export async function requestSignup(usernameRaw: string, emailRaw: string, ip: string | null): Promise { + const vu = validateUsername(usernameRaw); + if (!vu.ok) return { status: "error", error: vu.error }; + const ve = validateEmail(emailRaw); + if (!ve.ok) return { status: "error", error: ve.error }; + + const existingEmail = await db.select({ id: users.id }).from(users).where(eq(users.emailLower, ve.emailLower)); + if (existingEmail.length > 0) { + // Already a member — send a sign-in link instead; the page copy stays identical. + const token = await issueToken({ purpose: "login", emailLower: ve.emailLower, userId: existingEmail[0].id, ip }); + return { status: "sent", kind: "login", token, emailLower: ve.emailLower }; + } + const existingName = await db.select({ id: users.id }).from(users) + .where(eq(users.usernameLower, vu.username.toLowerCase())); + if (existingName.length > 0) return { status: "error", error: "That username is taken." }; + + const token = await issueToken({ purpose: "signup", emailLower: ve.emailLower, username: vu.username, ip }); + return { status: "sent", kind: "signup", token, emailLower: ve.emailLower }; +} + +export async function requestLogin(emailRaw: string, ip: string | null): + Promise<{ status: "sent"; token: string; emailLower: string } | { status: "silent" } | Err> { + const ve = validateEmail(emailRaw); + if (!ve.ok) return { status: "error", error: ve.error }; + const found = await db.select({ id: users.id }).from(users).where(eq(users.emailLower, ve.emailLower)); + if (found.length === 0) return { status: "silent" }; + const token = await issueToken({ purpose: "login", emailLower: ve.emailLower, userId: found[0].id, ip }); + return { status: "sent", token, emailLower: ve.emailLower }; +} + +export async function requestEmailChange(userId: number, emailRaw: string): + Promise<{ status: "sent"; token: string; emailLower: string } | Err> { + const ve = validateEmail(emailRaw); + if (!ve.ok) return { status: "error", error: ve.error }; + const clash = await db.select({ id: users.id }).from(users).where(eq(users.emailLower, ve.emailLower)); + if (clash.length > 0 && clash[0].id !== userId) { + return { status: "error", error: "That email is already attached to an account." }; + } + const token = await issueToken({ purpose: "email-change", emailLower: ve.emailLower, userId }); + return { status: "sent", token, emailLower: ve.emailLower }; +} +``` + +(`sessions` and `sql` imports are used in Task 5 — leave them; if the linter complains, add them in Task 5 instead.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run lib/auth/service.test.ts` → PASS. `npm run typecheck` → clean (drop unused imports if flagged). + +- [ ] **Step 5: Commit** + +```bash +git add lib/auth/service.ts lib/auth/service.test.ts +git commit -m "feat(auth): magic-link request flows (signup/login/email-change)" +``` + +### Task 5: Auth service — verify tokens + sessions + +**Files:** +- Modify: `lib/auth/service.ts` +- Test: `lib/auth/service.test.ts` (extend) + +**Interfaces:** +- Produces (used by T6–7 and all authed pages): + - `verifyToken(rawToken: string, usernameOverride?: string): Promise<{ status: "ok"; sessionToken: string; username: string } | { status: "invalid" | "expired" | "used" } | { status: "username-taken" }>` + - `signup`: creates the user (username = override ?? token's username); on a username collision returns `username-taken` **without consuming the token** so the re-pick form can resubmit with `usernameOverride`. + - `login`: creates a session for `userId`. + - `email-change`: updates `users.email_lower`, then creates a session. + - Marks the token used and creates a session in all `ok` paths. + - `getSessionUser(rawToken: string): Promise` — null when missing/expired (expired rows are deleted); slides `expires_at` forward when the session is >7 days old; bumps `users.last_seen_at` when stale by >5 minutes. + - `deleteSession(rawToken: string): Promise` + +- [ ] **Step 1: Write the failing tests (append to `lib/auth/service.test.ts`)** + +```ts +const { verifyToken, getSessionUser, deleteSession } = await import("./service"); +import { sessions } from "@/db/schema"; + +describe("verifyToken — signup", () => { + it("creates user + session; token is single-use", async () => { + const r = await requestSignup("Verifyme", "verify@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + const v = await verifyToken(r.token); + expect(v.status).toBe("ok"); + if (v.status !== "ok") return; + expect(v.username).toBe("Verifyme"); + const me = await getSessionUser(v.sessionToken); + expect(me?.username).toBe("Verifyme"); + expect(me?.role).toBe("member"); + // second use fails + expect((await verifyToken(r.token)).status).toBe("used"); + }); + + it("username race → username-taken, token survives, override works", async () => { + const r = await requestSignup("Racer", "racer@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + await ctx.db.insert(users).values({ username: "Racer2", usernameLower: "racer", emailLower: "sniped@x.co" }); + expect((await verifyToken(r.token)).status).toBe("username-taken"); + const v = await verifyToken(r.token, "Racer_alt"); + expect(v.status).toBe("ok"); + if (v.status === "ok") expect(v.username).toBe("Racer_alt"); + }); + + it("expired and garbage tokens are rejected", async () => { + const r = await requestSignup("Expiry", "expiry@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + await ctx.db.update(loginTokens).set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(loginTokens.tokenHash, hashToken(r.token))); + expect((await verifyToken(r.token)).status).toBe("expired"); + expect((await verifyToken("garbage")).status).toBe("invalid"); + }); +}); + +describe("sessions", () => { + it("expired sessions are deleted and return null", async () => { + const r = await requestLogin("verify@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + const v = await verifyToken(r.token); + if (v.status !== "ok") throw new Error("setup"); + await ctx.db.update(sessions).set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(sessions.tokenHash, hashToken(v.sessionToken))); + expect(await getSessionUser(v.sessionToken)).toBeNull(); + expect(await ctx.db.select().from(sessions).then(rows => + rows.filter(s => s.tokenHash === hashToken(v.sessionToken)))).toHaveLength(0); + }); + + it("old sessions slide forward on use", async () => { + const r = await requestLogin("verify@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + const v = await verifyToken(r.token); + if (v.status !== "ok") throw new Error("setup"); + const soon = new Date(Date.now() + 1 * 24 * 3_600_000); // 1 day left + await ctx.db.update(sessions).set({ expiresAt: soon }).where(eq(sessions.tokenHash, hashToken(v.sessionToken))); + await getSessionUser(v.sessionToken); + const [row] = (await ctx.db.select().from(sessions)).filter(s => s.tokenHash === hashToken(v.sessionToken)); + expect(row.expiresAt.getTime()).toBeGreaterThan(soon.getTime()); + }); + + it("deleteSession logs out", async () => { + const r = await requestLogin("verify@x.co", null); + if (r.status !== "sent") throw new Error("setup"); + const v = await verifyToken(r.token); + if (v.status !== "ok") throw new Error("setup"); + await deleteSession(v.sessionToken); + expect(await getSessionUser(v.sessionToken)).toBeNull(); + }); +}); +``` + +Also add `eq` to the test file's drizzle imports: `import { eq } from "drizzle-orm";` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run lib/auth/service.test.ts` → FAIL (`verifyToken` not exported). + +- [ ] **Step 3: Implement (append to `lib/auth/service.ts`)** + +```ts +async function createSession(userId: number): Promise { + const token = newToken(); + await db.insert(sessions).values({ + tokenHash: hashToken(token), userId, expiresAt: new Date(Date.now() + SESSION_TTL_MS), + }); + return token; +} + +export async function verifyToken(rawToken: string, usernameOverride?: string): Promise< + | { status: "ok"; sessionToken: string; username: string } + | { status: "invalid" | "expired" | "used" } + | { status: "username-taken" } +> { + const [t] = await db.select().from(loginTokens).where(eq(loginTokens.tokenHash, hashToken(rawToken))); + if (!t) return { status: "invalid" }; + if (t.usedAt) return { status: "used" }; + if (t.expiresAt.getTime() < Date.now()) return { status: "expired" }; + + let userId: number, username: string; + if (t.purpose === "signup") { + const wanted = usernameOverride ?? t.username ?? ""; + const vu = validateUsername(wanted); + if (!vu.ok) return { status: "username-taken" }; // bad override → re-pick again + const clash = await db.select({ id: users.id }).from(users) + .where(eq(users.usernameLower, vu.username.toLowerCase())); + if (clash.length > 0) return { status: "username-taken" }; // token NOT consumed + const [u] = await db.insert(users).values({ + username: vu.username, usernameLower: vu.username.toLowerCase(), emailLower: t.emailLower, + }).returning({ id: users.id, username: users.username }); + userId = u.id; username = u.username; + } else { + if (!t.userId) return { status: "invalid" }; + if (t.purpose === "email-change") { + await db.update(users).set({ emailLower: t.emailLower }).where(eq(users.id, t.userId)); + } + const [u] = await db.select({ id: users.id, username: users.username }).from(users).where(eq(users.id, t.userId)); + if (!u) return { status: "invalid" }; + userId = u.id; username = u.username; + } + await db.update(loginTokens).set({ usedAt: new Date() }).where(eq(loginTokens.tokenHash, t.tokenHash)); + return { status: "ok", sessionToken: await createSession(userId), username }; +} + +export async function getSessionUser(rawToken: string): Promise { + const tokenHash = hashToken(rawToken); + const [row] = await db.select({ + tokenHash: sessions.tokenHash, expiresAt: sessions.expiresAt, + id: users.id, username: users.username, role: users.role, signature: users.signature, + postCount: users.postCount, joinedAt: users.joinedAt, markAllReadAt: users.markAllReadAt, + bannedAt: users.bannedAt, bannedReason: users.bannedReason, lastSeenAt: users.lastSeenAt, + }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)) + .where(eq(sessions.tokenHash, tokenHash)); + if (!row) return null; + const now = Date.now(); + if (row.expiresAt.getTime() < now) { + await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash)); + return null; + } + if (row.expiresAt.getTime() - now < SESSION_TTL_MS - SLIDE_AFTER_MS) { + await db.update(sessions).set({ expiresAt: new Date(now + SESSION_TTL_MS), lastUsedAt: new Date() }) + .where(eq(sessions.tokenHash, tokenHash)); + } + if (!row.lastSeenAt || now - row.lastSeenAt.getTime() > 5 * 60_000) { + await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, row.id)); + } + return { + id: row.id, username: row.username, role: row.role as "member" | "admin", + signature: row.signature, postCount: row.postCount, joinedAt: row.joinedAt, + markAllReadAt: row.markAllReadAt, bannedAt: row.bannedAt, bannedReason: row.bannedReason, + }; +} + +export async function deleteSession(rawToken: string): Promise { + await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(rawToken))); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run lib/auth/service.test.ts` → PASS. `npm test && npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add lib/auth/service.ts lib/auth/service.test.ts +git commit -m "feat(auth): token verification, user creation, sliding sessions" +``` + +### Task 6: Cookie layer, auth pages + actions (join / login / verify / logout) + +**Files:** +- Create: `lib/auth/session.server.ts`, `app/forum/auth-actions.ts`, `app/forum/_components/forms.tsx`, `app/forum/_components/user-strip.tsx`, `app/forum/join/page.tsx`, `app/forum/login/page.tsx`, `app/forum/verify/page.tsx` +- Test: `app/forum/_components/forms.test.tsx` + +**Interfaces:** +- Consumes: T4–5 service functions, T3 `sendMagicLink`/`authOrigin`. +- Produces: + - `lib/auth/session.server.ts`: `SESSION_COOKIE = "ga_session"`, `currentUser(): Promise`, `requireUser(next?: string): Promise` (redirects to login), `setSessionCookie(token)`, `clearSessionCookie()`, `clientIp(): Promise`. + - `app/forum/auth-actions.ts`: `type AuthFormState = { error?: string; sent?: boolean; usernameTaken?: boolean }` and actions `joinAction`, `loginAction`, `verifyAction` (all `(prev: AuthFormState, fd: FormData) => Promise`), `logoutAction(): Promise`. + - `app/forum/_components/forms.tsx`: `type AuthAction`, `JoinForm({ action })`, `LoginForm({ action })`, `VerifyForm({ action, token })` — client components, `useActionState`, action passed as prop. + - `app/forum/_components/user-strip.tsx`: `UserStrip()` async server component — used on every forum page from T13 on. + +**Why verify is a POST:** email scanners prefetch GET links; a single-use token consumed on GET would be burned before the human clicks. `/forum/verify` therefore renders a "Complete sign-in" form and the server action consumes the token. + +- [ ] **Step 1: Write the failing component tests** + +```tsx +// app/forum/_components/forms.test.tsx +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { JoinForm, LoginForm, VerifyForm, type AuthAction } from "./forms"; + +const stub: AuthAction = async () => ({}); + +describe("auth forms", () => { + it("JoinForm renders username + email inputs with old-school constraints", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('name="username"'); + expect(html).toContain('name="email"'); + expect(html).toContain('pattern="[A-Za-z0-9_-]+"'); + }); + it("LoginForm renders only an email input", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('name="email"'); + expect(html).not.toContain('name="username"'); + }); + it("VerifyForm carries the token in a hidden input", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('type="hidden"'); + expect(html).toContain('value="T123"'); + expect(html).toContain("Complete sign-in"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run app/forum` → FAIL (module doesn't exist). + +- [ ] **Step 3: Implement the cookie layer** + +```ts +// lib/auth/session.server.ts +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getSessionUser, SESSION_TTL_MS, type SessionUser } from "./service"; + +export const SESSION_COOKIE = "ga_session"; + +export async function currentUser(): Promise { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + return token ? getSessionUser(token) : null; +} + +export async function requireUser(next = "/forum"): Promise { + const user = await currentUser(); + if (!user) redirect(`/forum/login?next=${encodeURIComponent(next)}`); + return user; +} + +export async function setSessionCookie(token: string): Promise { + (await cookies()).set(SESSION_COOKIE, token, { + httpOnly: true, secure: process.env.NODE_ENV === "production", + sameSite: "lax", path: "/", maxAge: Math.floor(SESSION_TTL_MS / 1000), + }); +} + +export async function clearSessionCookie(): Promise { + (await cookies()).delete(SESSION_COOKIE); +} + +export async function clientIp(): Promise { + const fwd = (await headers()).get("x-forwarded-for"); + return fwd ? fwd.split(",")[0].trim() : null; +} +``` + +- [ ] **Step 4: Implement the actions** + +```ts +// app/forum/auth-actions.ts +"use server"; + +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { requestSignup, requestLogin, verifyToken, deleteSession } from "@/lib/auth/service"; +import { sendMagicLink, authOrigin } from "@/lib/auth/email"; +import { setSessionCookie, clearSessionCookie, clientIp, SESSION_COOKIE } from "@/lib/auth/session.server"; + +export type AuthFormState = { error?: string; sent?: boolean; usernameTaken?: boolean }; + +const str = (fd: FormData, k: string): string => { + const v = fd.get(k); + return typeof v === "string" ? v : ""; +}; + +const verifyUrl = (token: string) => `${authOrigin()}/forum/verify?token=${token}`; + +export async function joinAction(_prev: AuthFormState, fd: FormData): Promise { + const r = await requestSignup(str(fd, "username"), str(fd, "email"), await clientIp()); + if (r.status === "error") return { error: r.error }; + await sendMagicLink({ to: r.emailLower, url: verifyUrl(r.token), kind: r.kind }); + return { sent: true }; +} + +export async function loginAction(_prev: AuthFormState, fd: FormData): Promise { + const r = await requestLogin(str(fd, "email"), await clientIp()); + if (r.status === "error") return { error: r.error }; + if (r.status === "sent") await sendMagicLink({ to: r.emailLower, url: verifyUrl(r.token), kind: "login" }); + return { sent: true }; // "silent" renders identically — no account enumeration +} + +export async function verifyAction(_prev: AuthFormState, fd: FormData): Promise { + const v = await verifyToken(str(fd, "token"), str(fd, "username") || undefined); + if (v.status === "ok") { + await setSessionCookie(v.sessionToken); + redirect("/forum"); + } + if (v.status === "username-taken") return { usernameTaken: true }; + return { + error: v.status === "expired" + ? "That link has expired — request a new one." + : "That link is invalid or was already used.", + }; +} + +export async function logoutAction(): Promise { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (token) await deleteSession(token); + await clearSessionCookie(); + redirect("/forum"); +} +``` + +- [ ] **Step 5: Implement the form components** + +```tsx +// app/forum/_components/forms.tsx +"use client"; + +import { useActionState } from "react"; +import type { AuthFormState } from "../auth-actions"; + +export type AuthAction = (prev: AuthFormState, fd: FormData) => Promise; + +const field = "border border-line bg-transparent px-2 py-1"; +const btn = "border border-line px-3 py-1 hover:border-line-soft"; + +export function JoinForm({ action }: { action: AuthAction }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + if (state.sent) { + return

Check your email — we sent a link to finish joining. It works once and expires in 15 minutes.

; + } + return ( + + + + {state.error &&

{state.error}

} + + + ); +} + +export function LoginForm({ action }: { action: AuthAction }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + if (state.sent) { + return

If that email has an account, a sign-in link is on its way. It works once and expires in 15 minutes.

; + } + return ( +
+ + {state.error &&

{state.error}

} + +
+ ); +} + +export function VerifyForm({ action, token }: { action: AuthAction; token: string }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + return ( +
+ + {state.usernameTaken && ( + + )} + {state.error &&

{state.error}

} + +
+ ); +} +``` + +- [ ] **Step 6: Implement the user strip and the three pages** + +```tsx +// app/forum/_components/user-strip.tsx +import Link from "next/link"; +import { currentUser } from "@/lib/auth/session.server"; +import { logoutAction } from "../auth-actions"; + +/** The classic "Signed in as …" strip shown at the top of every forum page. */ +export async function UserStrip() { + const user = await currentUser(); + return ( +
+ {user ? ( + <> + Signed in as {user.username} + Settings + {user.role === "admin" && Admin} +
+ + ) : ( + <> + Log in + Join + + )} +
+ ); +} +``` + +```tsx +// app/forum/join/page.tsx +import type { Metadata } from "next"; +import Link from "next/link"; +import { Container } from "@/app/_components/container"; +import { Doc, Breadcrumb } from "@/app/_components/doc"; +import { getExperience } from "@/lib/experience.server"; +import { JoinForm } from "../_components/forms"; +import { joinAction } from "../auth-actions"; + +export const metadata: Metadata = { title: "Join the forum", robots: { index: false } }; + +export default async function JoinPage() { + const experience = await getExperience(); + const body = ( + <> +

Pick a username, get a magic link — no password, ever.

+
+

Already a member? Log in

+ + ); + if (experience === "minimal") { + return ( + + + +

Join the forum

+ {body} +
+
+ ); + } + return ( + +

Join the forum

+ {body} +
+ ); +} +``` + +`app/forum/login/page.tsx` is identical in shape (title "Log in", ``, footer link to `/forum/join` reading "New here? Join the forum", and the same `robots: { index: false }` metadata). + +```tsx +// app/forum/verify/page.tsx +import type { Metadata } from "next"; +import Link from "next/link"; +import { Container } from "@/app/_components/container"; +import { getExperience } from "@/lib/experience.server"; +import { VerifyForm } from "../_components/forms"; +import { verifyAction } from "../auth-actions"; + +export const metadata: Metadata = { title: "Complete sign-in", robots: { index: false } }; + +export default async function VerifyPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) { + const { token } = await searchParams; + await getExperience(); // keeps rendering dynamic like the rest of the site + return ( + +

Almost there

+ {token ? ( +
+ ) : ( +

This link is missing its token. Request a new one.

+ )} +
+ ); +} +``` + +- [ ] **Step 7: Run tests, typecheck** + +Run: `npx vitest run app/forum && npm run typecheck` → PASS/clean. + +- [ ] **Step 8: Manual verification (dev flow)** + +With `npm run db:up` and migrations applied (`npm run db:migrate`), start the dev server. Visit `/forum/join`, submit a username + email, copy the magic link from the **server console** (`[auth] magic link for …`), open it, click "Complete sign-in" — expect redirect to `/forum` (404 for now — Task 13 adds the page; the `ga_session` cookie should be set, check devtools). Also verify `/forum/login` with the same email, and that a bogus `?token=x` shows the invalid-link error. + +- [ ] **Step 9: Commit** + +```bash +git add lib/auth/session.server.ts app/forum +git commit -m "feat(auth): join/login/verify pages, session cookie, user strip" +``` + +### Task 7: Settings page + make-admin script + +**Files:** +- Create: `app/forum/settings/page.tsx`, `scripts/make-admin.ts` +- Modify: `lib/auth/service.ts` (add `updateSignature`), `app/forum/auth-actions.ts` (add `settingsAction`, `emailChangeAction`), `app/forum/_components/forms.tsx` (add `SettingsForm`, `EmailChangeForm`), `package.json` (script) +- Test: `lib/auth/service.test.ts` (extend), `app/forum/_components/forms.test.tsx` (extend) + +**Interfaces:** +- Produces: + - `updateSignature(userId: number, raw: string): Promise<{ ok: true } | { ok: false; error: string }>` — trims; max 200 chars (import `SIGNATURE_MAX` inline as the literal `200` for now; Task 9 moves it to `lib/forum/constants.ts` and this function switches to importing it). + - `settingsAction`, `emailChangeAction` — `AuthFormState`-shaped actions. + - `SettingsForm({ action, initialSignature })`, `EmailChangeForm({ action })` in forms.tsx. + +- [ ] **Step 1: Write the failing tests** + +Append to `lib/auth/service.test.ts`: + +```ts +describe("updateSignature", () => { + it("saves a trimmed signature and clears an empty one", async () => { + const [u] = await ctx.db.select({ id: users.id }).from(users); + expect(await (await import("./service")).updateSignature(u.id, " We honk at dawn ")).toEqual({ ok: true }); + let [row] = await ctx.db.select({ signature: users.signature }).from(users).where(eq(users.id, u.id)); + expect(row.signature).toBe("We honk at dawn"); + await (await import("./service")).updateSignature(u.id, " "); + [row] = await ctx.db.select({ signature: users.signature }).from(users).where(eq(users.id, u.id)); + expect(row.signature).toBeNull(); + }); + it("rejects signatures over 200 chars", async () => { + const [u] = await ctx.db.select({ id: users.id }).from(users); + const r = await (await import("./service")).updateSignature(u.id, "x".repeat(201)); + expect(r.ok).toBe(false); + }); +}); +``` + +Append to `forms.test.tsx`: + +```tsx +import { SettingsForm, EmailChangeForm } from "./forms"; + +it("SettingsForm shows the current signature and its limit", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("honk"); + expect(html).toContain('maxlength="200"'); // React static markup lowercases attributes +}); +it("EmailChangeForm never displays an email address", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("@"); // page copy avoids emails entirely +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run lib/auth app/forum` → FAIL. + +- [ ] **Step 3: Implement** + +Append to `lib/auth/service.ts`: + +```ts +export async function updateSignature(userId: number, raw: string): Promise<{ ok: true } | { ok: false; error: string }> { + const signature = raw.trim(); + if (signature.length > 200) return { ok: false, error: "Signatures max out at 200 characters." }; + await db.update(users).set({ signature: signature || null }).where(eq(users.id, userId)); + return { ok: true }; +} +``` + +Append to `app/forum/auth-actions.ts`: + +```ts +export async function settingsAction(_prev: AuthFormState, fd: FormData): Promise { + const user = await requireUser("/forum/settings"); + const r = await updateSignature(user.id, str(fd, "signature")); + if (!r.ok) return { error: r.error }; + return { sent: true }; // rendered as "Saved." +} + +export async function emailChangeAction(_prev: AuthFormState, fd: FormData): Promise { + const user = await requireUser("/forum/settings"); + const r = await requestEmailChange(user.id, str(fd, "email")); + if (r.status === "error") return { error: r.error }; + await sendMagicLink({ to: r.emailLower, url: verifyUrl(r.token), kind: "email-change" }); + return { sent: true }; +} +``` + +(add `requireUser` to the session.server import and `requestEmailChange`, `updateSignature` to the service import.) + +Append to `forms.tsx`: + +```tsx +export function SettingsForm({ action, initialSignature }: { action: AuthAction; initialSignature: string }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + return ( +
+