diff --git a/docs/superpowers/plans/2026-05-02-b-auth.md b/docs/superpowers/plans/2026-05-02-b-auth.md new file mode 100644 index 0000000..0a78951 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-b-auth.md @@ -0,0 +1,1396 @@ +# B-auth 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:** Add an in-app login flow to `cm-web-next` with WebAuthn passkey + `CM_AGENT_ID`/`CM_AGENT_PASSWORD` password fallback, all gated by Next.js middleware. Auth state lives entirely in `cm-web-next` — no api-server changes, no mysql schema change. + +**Architecture:** `iron-session` signed `httpOnly` cookie for sessions. `@simplewebauthn/server` + `@simplewebauthn/browser` for WebAuthn flows, called via Server Actions (no public `/api/*`). Passkeys stored as JSON in a docker volume (`/data/auth/passkeys.json`) with atomic writes serialized by an in-process write lock. Middleware redirects unauthenticated requests to `/cm-auth` (avoiding the well-known `/login` path scanners hit by default). + +**Tech Stack:** Next.js 15 App Router, React 19, Tailwind v4, `iron-session ^8.0.0`, `@simplewebauthn/server ^11.0.0`, `@simplewebauthn/browser ^11.0.0`. Node `crypto.timingSafeEqual` for constant-time password compare. + +**Spec:** [docs/superpowers/specs/2026-05-02-b-auth-design.md](../specs/2026-05-02-b-auth-design.md) + +--- + +## File Map + +| File | Operation | Owner | +|---|---|---| +| `web/package.json` | Modify | hand — add three deps | +| `web/lib/auth.ts` | Create | hand — iron-session wrapper, session CRUD | +| `web/lib/auth-store.ts` | Create | hand — JSON-file passkey store with atomic writes + write lock | +| `web/lib/auth-rp.ts` | Create | hand — WebAuthn relying-party helpers (rpID, origin) | +| `web/app/auth-actions.ts` | Create | hand — all Server Actions | +| `web/middleware.ts` | Create | hand — gate routes | +| `web/app/cm-auth/page.tsx` | Create | hand (Server Component shell, reads passkey-exists flag) | +| `web/app/cm-auth/auth-form.tsx` | Create | frontend-design — login form Client Component | +| `web/app/cm-passkeys/page.tsx` | Create | hand (Server Component shell, fetches passkey list) | +| `web/app/cm-passkeys/passkey-list.tsx` | Create | frontend-design — passkey list + add/remove Client Component | +| `web/components/nav.tsx` | Modify | frontend-design — add account menu (username, settings, sign out) | +| `docker-compose.yml` | Modify | hand — add `web-next-auth-data` named volume + mount | +| `docker-compose.override.yml` | Modify | hand — same mount in dev override | +| `envs/dev/.env.example` | Modify | hand — add `CM_AUTH_SECRET` placeholder | +| `envs/rex/.env.example` | Modify | hand — same | +| `envs/siong/.env.example` | Modify | hand — same | +| `AGENTS.md` | Modify | hand — Auth subsection | + +Three frontend-design invocations for the UI (auth form, passkey list, nav account menu). + +--- + +## Task 1: Add auth dependencies to package.json + +**Files:** +- Modify: `web/package.json` + +- [ ] **Step 1: Update dependencies block** + +In `web/package.json`, add three entries to `dependencies`: + +```json +{ + "name": "cm-web-next", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "15.1.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "iron-session": "^8.0.0", + "@simplewebauthn/server": "^11.0.0", + "@simplewebauthn/browser": "^11.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.0", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.2" + } +} +``` + +(The Docker build runs `npm install` and resolves these on first build — no host-side `npm install` needed; we don't commit a lockfile per the established pattern from B1.) + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/package.json && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "build(web): add iron-session and simplewebauthn deps" +``` + +--- + +## Task 2: Add `CM_AUTH_SECRET` to all env.example files + +**Files:** +- Modify: `envs/dev/.env.example` +- Modify: `envs/rex/.env.example` +- Modify: `envs/siong/.env.example` + +- [ ] **Step 1: Generate sample secrets per env (32 bytes hex)** + +For dev, a stable known secret is fine since dev .env is committed-as-template. For rex/siong, the example shows the format and instructs the operator to rotate. + +In `envs/dev/.env.example`, find the `=== Runtime ===` section and add immediately after `CM_DEBUG`: + +``` +# === Auth (cm-web-next session signing) === +# 64-character hex (32 bytes). Generate with: openssl rand -hex 32 +# Rotating this secret invalidates all existing sessions (forces re-login). +CM_AUTH_SECRET=devsecret-replace-with-openssl-rand-hex-32-for-real-deploys +``` + +In `envs/rex/.env.example`, append the same block at the end. + +In `envs/siong/.env.example`, append the same block at the end. + +- [ ] **Step 2: Verify all three files have the new line** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +grep -H "^CM_AUTH_SECRET=" envs/*/.env.example +``` + +Expected: three lines, one per deployment. + +- [ ] **Step 3: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add envs/dev/.env.example envs/rex/.env.example envs/siong/.env.example && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(envs): add CM_AUTH_SECRET to all .env.example templates" +``` + +--- + +## Task 3: Mount the auth-data volume on `web-next` + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `docker-compose.override.yml` + +- [ ] **Step 1: Add the volume + mount in base compose** + +In `docker-compose.yml`, find the existing `web-next` service block and add a `volumes:` directive that mounts the named volume at `/data/auth`: + +Find: + +```yaml + web-next: + image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}" + container_name: ${CM_DEPLOY_NAME:-cm}-web-next + restart: unless-stopped + ports: + - "${CM_WEB_NEXT_HOST_PORT:-8010}:3000" + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + API_BASE_URL: http://api-server:3000 + volumes: + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + networks: + - bot-network + depends_on: + - api-server +``` + +Add `CM_AUTH_SECRET` to the `environment:` block, add a new mount to `volumes:`, and append a top-level `volumes:` declaration if there isn't one already (the existing override has `mysql-data`; we add the auth-data volume next to it): + +```yaml + web-next: + image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}" + container_name: ${CM_DEPLOY_NAME:-cm}-web-next + restart: unless-stopped + ports: + - "${CM_WEB_NEXT_HOST_PORT:-8010}:3000" + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + API_BASE_URL: http://api-server:3000 + CM_AUTH_SECRET: ${CM_AUTH_SECRET} + volumes: + - web-next-auth-data:/data/auth + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + networks: + - bot-network + depends_on: + - api-server +``` + +Append a top-level `volumes:` block at the end of `docker-compose.yml` (or extend the existing one if present): + +```yaml +volumes: + web-next-auth-data: + name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data +``` + +- [ ] **Step 2: Override file gets the same mount path implicitly via base merge** + +Compose merges service definitions across files. The `volumes:` array on `web-next` in base is what dev compose sees, no override change needed for the mount itself. But the dev override needs the top-level `volumes:` declaration (since mysql-data is also there) to know about the named volume. + +Open `docker-compose.override.yml`. Find the existing top-level `volumes:` block (already declares `mysql-data`). Add `web-next-auth-data` next to it: + +```yaml +volumes: + mysql-data: + name: ${CM_DEPLOY_NAME:-cm}-mysql-data + web-next-auth-data: + name: ${CM_DEPLOY_NAME:-cm}-web-next-auth-data +``` + +- [ ] **Step 3: YAML structural validation** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +.venv/bin/python -c " +import yaml +with open('docker-compose.yml') as f: + base = yaml.safe_load(f) +wn = base['services']['web-next'] +assert any('web-next-auth-data:/data/auth' in v for v in wn['volumes']), 'auth volume mount missing on web-next' +assert wn['environment']['CM_AUTH_SECRET'] == '\${CM_AUTH_SECRET}' +assert 'web-next-auth-data' in base.get('volumes', {}), 'top-level volume missing in base' +print('base: web-next-auth-data wired') + +with open('docker-compose.override.yml') as f: + over = yaml.safe_load(f) +assert 'web-next-auth-data' in over['volumes'] +print('override: web-next-auth-data declared') +" +``` + +Expected: + +``` +base: web-next-auth-data wired +override: web-next-auth-data declared +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add docker-compose.yml docker-compose.override.yml && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(compose): mount web-next-auth-data volume + pass CM_AUTH_SECRET" +``` + +--- + +## Task 4: Session helper (`web/lib/auth.ts`) + +**Files:** +- Create: `web/lib/auth.ts` + +- [ ] **Step 1: Write the session helper** + +Create `web/lib/auth.ts`: + +```typescript +import "server-only"; +import { cookies } from "next/headers"; +import { sealData, unsealData } from "iron-session"; + +const COOKIE_NAME = "cm_auth"; +const COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export type Session = { + username: string; + authenticatedAt: number; + // Transient WebAuthn challenge held while a register/authenticate flow + // is in progress. Cleared on the next finish*() call. + pendingChallenge?: { + kind: "register" | "authenticate"; + challenge: string; + expiresAt: number; + }; +}; + +function secret(): string { + const s = process.env.CM_AUTH_SECRET; + if (!s || s.length < 32) { + throw new Error("CM_AUTH_SECRET missing or shorter than 32 chars"); + } + return s; +} + +export async function getSession(): Promise { + const jar = await cookies(); + const raw = jar.get(COOKIE_NAME)?.value; + if (!raw) return null; + try { + return await unsealData(raw, { password: secret() }); + } catch { + return null; + } +} + +export async function setSession(session: Session): Promise { + const sealed = await sealData(session, { + password: secret(), + ttl: COOKIE_TTL_SECONDS, + }); + const jar = await cookies(); + jar.set(COOKIE_NAME, sealed, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_TTL_SECONDS, + }); +} + +export async function clearSession(): Promise { + const jar = await cookies(); + jar.delete(COOKIE_NAME); +} + +export async function requireSession(): Promise { + const s = await getSession(); + if (!s) throw new Error("Unauthenticated"); + return s; +} +``` + +`server-only` is a Next.js poison-import: the build will fail if any client component transitively imports this file. + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/lib/auth.ts && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): add iron-session wrapper (web/lib/auth.ts)" +``` + +--- + +## Task 5: Passkey JSON store (`web/lib/auth-store.ts`) + +**Files:** +- Create: `web/lib/auth-store.ts` + +- [ ] **Step 1: Write the store** + +Create `web/lib/auth-store.ts`: + +```typescript +import "server-only"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"; + +const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json"; + +export type PasskeyRecord = { + id: string; + publicKey: string; + counter: number; + transports: AuthenticatorTransportFuture[]; + name: string; + createdAt: string; +}; + +type StoreShape = Record; + +// Single-process write lock. Each mutation chains onto this promise so +// concurrent requests serialize. Sufficient for one container; if we +// ever scale horizontally, switch to a real lockfile or move to mysql. +let writeLock: Promise = Promise.resolve(); + +async function readAll(): Promise { + try { + const raw = await fs.readFile(FILE_PATH, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as StoreShape; + } + return {}; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } +} + +async function writeAtomic(data: StoreShape): Promise { + const dir = path.dirname(FILE_PATH); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${FILE_PATH}.tmp`; + const handle = await fs.open(tmp, "w"); + try { + await handle.writeFile(JSON.stringify(data, null, 2)); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(tmp, FILE_PATH); +} + +function withLock(fn: () => Promise): Promise { + const next = writeLock.then(fn, fn); + // Keep the chain going regardless of whether `fn` resolved or threw. + writeLock = next.then( + () => undefined, + () => undefined, + ); + return next; +} + +export async function readPasskeys(username: string): Promise { + const all = await readAll(); + return all[username] ?? []; +} + +export async function appendPasskey(username: string, rec: PasskeyRecord): Promise { + await withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + list.push(rec); + all[username] = list; + await writeAtomic(all); + }); +} + +export async function removePasskey(username: string, credentialId: string): Promise { + return withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + const next = list.filter((p) => p.id !== credentialId); + if (next.length === list.length) return false; + all[username] = next; + await writeAtomic(all); + return true; + }); +} + +export async function bumpCounter( + username: string, + credentialId: string, + counter: number, +): Promise { + await withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + const idx = list.findIndex((p) => p.id === credentialId); + if (idx === -1) return; + list[idx] = { ...list[idx], counter }; + all[username] = list; + await writeAtomic(all); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/lib/auth-store.ts && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): JSON-file passkey store with atomic writes + write lock" +``` + +--- + +## Task 6: WebAuthn relying-party helpers (`web/lib/auth-rp.ts`) + +**Files:** +- Create: `web/lib/auth-rp.ts` + +- [ ] **Step 1: Write the helpers** + +WebAuthn requires a relying-party identifier (rpID = the domain) and an origin (`https://...`). These come from request headers in production (since the public domain is set at the aaPanel layer, not in `cm-web-next`'s env). We derive them from the `Host` header on every Server Action invocation. + +Create `web/lib/auth-rp.ts`: + +```typescript +import "server-only"; +import { headers } from "next/headers"; + +export type RpInfo = { + rpID: string; + origin: string; + rpName: string; +}; + +export async function getRpInfo(): Promise { + const hdrs = await headers(); + // x-forwarded-host is what aaPanel sets; fall back to host for direct dev. + const host = hdrs.get("x-forwarded-host") ?? hdrs.get("host") ?? "localhost:8010"; + // Drop any port from the rpID (WebAuthn requires hostname only, no port). + const rpID = host.split(":")[0]; + // Origin includes protocol + host + port. + const proto = hdrs.get("x-forwarded-proto") ?? "http"; + const origin = `${proto}://${host}`; + return { + rpID, + origin, + rpName: "CM Bot V2", + }; +} +``` + +Why derive from headers: same `cm-web-next` image runs at `http://localhost:8010` (dev) and `https://heng.04080616.xyz` (prod) — the rpID/origin differs. Hardcoding either breaks the other. Headers from the real request always reflect the user's actual origin. + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/lib/auth-rp.ts && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): WebAuthn relying-party helper (host-derived rpID/origin)" +``` + +--- + +## Task 7: Server Actions (`web/app/auth-actions.ts`) + +**Files:** +- Create: `web/app/auth-actions.ts` + +- [ ] **Step 1: Write the actions** + +Create `web/app/auth-actions.ts`: + +```typescript +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { timingSafeEqual } from "node:crypto"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, +} from "@simplewebauthn/server"; +import { + getSession, + setSession, + clearSession, + requireSession, +} from "@/lib/auth"; +import { getRpInfo } from "@/lib/auth-rp"; +import { + readPasskeys, + appendPasskey, + removePasskey, + bumpCounter, +} from "@/lib/auth-store"; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +function constantTimeEqual(a: string, b: string): boolean { + // timingSafeEqual requires equal-length buffers. Pad both to the + // longer length with NULs (which won't appear in either real value) + // so the compare itself is constant-time. Then check the original + // lengths matched as a final guard. + const ab = Buffer.from(a); + const bb = Buffer.from(b); + const len = Math.max(ab.length, bb.length); + const ap = Buffer.alloc(len); + const bp = Buffer.alloc(len); + ab.copy(ap); + bb.copy(bp); + return timingSafeEqual(ap, bp) && ab.length === bb.length; +} + +// ---- Password login ---- + +export async function loginWithPassword( + username: string, + password: string, +): Promise { + const expectedUsername = process.env.CM_AGENT_ID ?? ""; + const expectedPassword = process.env.CM_AGENT_PASSWORD ?? ""; + if (!expectedUsername || !expectedPassword) { + return { ok: false, error: "Server credentials not configured" }; + } + const usernameOk = constantTimeEqual(username, expectedUsername); + const passwordOk = constantTimeEqual(password, expectedPassword); + if (!usernameOk || !passwordOk) { + return { ok: false, error: "Invalid credentials" }; + } + await setSession({ + username: expectedUsername, + authenticatedAt: Date.now(), + }); + return { ok: true }; +} + +export async function logout(): Promise { + await clearSession(); + redirect("/cm-auth"); +} + +// ---- WebAuthn registration (requires authenticated session) ---- + +export async function beginRegistration() { + const session = await requireSession(); + const { rpID, rpName } = await getRpInfo(); + const existing = await readPasskeys(session.username); + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: session.username, + userID: new TextEncoder().encode(session.username), + attestationType: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + authenticatorAttachment: "platform", + }, + excludeCredentials: existing.map((p) => ({ + id: p.id, + transports: p.transports, + })), + }); + await setSession({ + ...session, + pendingChallenge: { + kind: "register", + challenge: options.challenge, + expiresAt: Date.now() + 5 * 60 * 1000, + }, + }); + return options; +} + +export async function finishRegistration( + response: RegistrationResponseJSON, + deviceName: string, +): Promise { + const session = await requireSession(); + const pending = session.pendingChallenge; + if (!pending || pending.kind !== "register" || pending.expiresAt < Date.now()) { + return { ok: false, error: "Registration challenge expired or missing" }; + } + const { rpID, origin } = await getRpInfo(); + let verification; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: pending.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Verification failed", + }; + } + if (!verification.verified || !verification.registrationInfo) { + return { ok: false, error: "Registration not verified" }; + } + const info = verification.registrationInfo; + const cred = info.credential; + const trimmedName = (deviceName || "").trim() || "Unnamed device"; + await appendPasskey(session.username, { + id: cred.id, + publicKey: Buffer.from(cred.publicKey).toString("base64url"), + counter: cred.counter, + transports: response.response.transports ?? [], + name: trimmedName, + createdAt: new Date().toISOString(), + }); + // Clear the pending challenge. + await setSession({ + username: session.username, + authenticatedAt: session.authenticatedAt, + }); + revalidatePath("/cm-passkeys"); + return { ok: true }; +} + +// ---- WebAuthn authentication (NO session required — this IS the login) ---- + +export async function beginAuthentication() { + const expectedUsername = process.env.CM_AGENT_ID ?? ""; + const passkeys = await readPasskeys(expectedUsername); + const { rpID } = await getRpInfo(); + const options = await generateAuthenticationOptions({ + rpID, + userVerification: "preferred", + allowCredentials: passkeys.map((p) => ({ + id: p.id, + transports: p.transports, + })), + }); + // Store challenge in a fresh session-shell (no auth yet). On verify + // we'll upgrade this shell to a full session. + const existing = (await getSession()) ?? { + username: "", + authenticatedAt: 0, + }; + await setSession({ + ...existing, + pendingChallenge: { + kind: "authenticate", + challenge: options.challenge, + expiresAt: Date.now() + 5 * 60 * 1000, + }, + }); + return options; +} + +export async function finishAuthentication( + response: AuthenticationResponseJSON, +): Promise { + const expectedUsername = process.env.CM_AGENT_ID ?? ""; + if (!expectedUsername) { + return { ok: false, error: "Server identity not configured" }; + } + const session = await getSession(); + const pending = session?.pendingChallenge; + if (!pending || pending.kind !== "authenticate" || pending.expiresAt < Date.now()) { + return { ok: false, error: "Authentication challenge expired or missing" }; + } + const passkeys = await readPasskeys(expectedUsername); + const stored = passkeys.find((p) => p.id === response.id); + if (!stored) { + return { ok: false, error: "Unknown credential" }; + } + const { rpID, origin } = await getRpInfo(); + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: pending.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: stored.id, + publicKey: Buffer.from(stored.publicKey, "base64url"), + counter: stored.counter, + transports: stored.transports, + }, + requireUserVerification: false, + }); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Verification failed", + }; + } + if (!verification.verified) { + return { ok: false, error: "Authentication not verified" }; + } + await bumpCounter(expectedUsername, stored.id, verification.authenticationInfo.newCounter); + await setSession({ + username: expectedUsername, + authenticatedAt: Date.now(), + }); + return { ok: true }; +} + +// ---- Passkey management ---- + +export async function deletePasskey(credentialId: string): Promise { + const session = await requireSession(); + const removed = await removePasskey(session.username, credentialId); + if (!removed) return { ok: false, error: "Passkey not found" }; + revalidatePath("/cm-passkeys"); + return { ok: true }; +} + +export async function hasPasskeysForLogin(): Promise { + const expectedUsername = process.env.CM_AGENT_ID ?? ""; + if (!expectedUsername) return false; + const list = await readPasskeys(expectedUsername); + return list.length > 0; +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/app/auth-actions.ts && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): Server Actions for password login + WebAuthn passkey flows" +``` + +--- + +## Task 8: Middleware (`web/middleware.ts`) + +**Files:** +- Create: `web/middleware.ts` + +- [ ] **Step 1: Write the middleware** + +Create `web/middleware.ts`: + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { unsealData } from "iron-session"; + +const COOKIE_NAME = "cm_auth"; +const PUBLIC_PATHS = new Set(["/cm-auth"]); + +type SessionShape = { + username: string; + authenticatedAt: number; +}; + +async function isAuthenticated(req: NextRequest): Promise { + const raw = req.cookies.get(COOKIE_NAME)?.value; + if (!raw) return false; + const secret = process.env.CM_AUTH_SECRET; + if (!secret || secret.length < 32) return false; + try { + const session = await unsealData(raw, { password: secret }); + return Boolean(session?.username); + } catch { + return false; + } +} + +export async function middleware(req: NextRequest) { + const path = req.nextUrl.pathname; + // Strip trailing slash for the comparison. + const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; + if (PUBLIC_PATHS.has(normalized)) return NextResponse.next(); + + if (await isAuthenticated(req)) return NextResponse.next(); + + const url = req.nextUrl.clone(); + url.pathname = "/cm-auth"; + url.searchParams.set("next", path); + return NextResponse.redirect(url); +} + +export const config = { + // Skip framework internals, the manifest endpoint, and the auto-generated + // icon/apple-icon routes (those need to be reachable for the PWA install). + matcher: [ + "/((?!_next|icon$|apple-icon$|manifest.webmanifest|favicon.ico).*)", + ], +}; +``` + +The matcher regex excludes Next.js internal paths and the public manifest/icon endpoints. Server Actions are routed through Next.js's framework layer (POST to the page URL with `Next-Action` header) — middleware sees them as POST requests to a page path. Since the page path requires auth, the action is also gated. Login-related Server Actions live OFF `/cm-auth`, so they go through the public path. + +Wait — Server Actions are bound to where they're imported, but the request URL is the page that triggered them. If the user calls `loginWithPassword` from `/cm-auth/auth-form.tsx`, the request URL is `/cm-auth`. That's in `PUBLIC_PATHS`, so middleware lets it through. + +For Server Actions called from authenticated pages (like `deletePasskey` from `/cm-passkeys`), middleware sees `/cm-passkeys` and requires auth — correct, since the action checks `requireSession()` internally too. + +- [ ] **Step 2: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/middleware.ts && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): middleware redirects unauthenticated requests to /cm-auth" +``` + +--- + +## Task 9: `/cm-auth` page shell + frontend-design auth form + +**Files:** +- Create: `web/app/cm-auth/page.tsx` +- Create: `web/app/cm-auth/auth-form.tsx` (frontend-design) + +- [ ] **Step 1: Create the directory** + +```bash +mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/cm-auth +``` + +- [ ] **Step 2: Write the Server Component shell** + +Create `web/app/cm-auth/page.tsx`: + +```typescript +import { hasPasskeysForLogin } from "@/app/auth-actions"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import AuthForm from "./auth-form"; + +type SearchParams = { next?: string }; + +export default async function AuthPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // If already authenticated, bounce to the destination. + const session = await getSession(); + if (session) { + const dest = (await searchParams).next ?? "/"; + redirect(dest); + } + const passkeysAvailable = await hasPasskeysForLogin(); + const next = (await searchParams).next ?? "/"; + return ; +} + +// Don't pre-render — the passkey-availability flag and session check both +// depend on runtime state. +export const dynamic = "force-dynamic"; +``` + +- [ ] **Step 3: Invoke frontend-design for the form** + +Use the Skill tool with `skill="frontend-design:frontend-design"` and: + +``` +Generate a Next.js 15 + Tailwind v4 Client Component at +`web/app/cm-auth/auth-form.tsx`. + +Visual identity matches the rest of the dashboard: refined SaaS, +white card on zinc-50, ring-1 zinc-200, no hard 2px borders, rounded-2xl, +emerald-100 accents, sans for chrome (no font-mono on the chrome). + +The component is the login form for an internal CRUD dashboard. One +operator per deployment; identity = CM_AGENT_ID env var. + +Props: + type Props = { + passkeysAvailable: boolean; + next: string; + }; + +Imports: + import { loginWithPassword, beginAuthentication, finishAuthentication } from "@/app/auth-actions"; + import { startAuthentication } from "@simplewebauthn/browser"; + import { useRouter } from "next/navigation"; + // useState, useTransition from react + +Behavior: + +1. If passkeysAvailable && (browser supports + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() — + feature-detect at mount time with useEffect+useState), render a + primary "Sign in with passkey" button at the top. + +2. The passkey button click flow: + - Call await beginAuthentication() to get options. + - Call await startAuthentication({ optionsJSON: options }) — triggers + Face ID / fingerprint UI. + - Call await finishAuthentication(response). + - On { ok: true }: router.push(next || "/"). + - On error: show inline red error below the button. + +3. Always render the username + password form below the passkey button + (or as the only option when no passkey is enrolled). On submit: + - await loginWithPassword(username, password). + - On { ok: true }: router.push(next || "/"). + - On error: show inline red error below the form. + +4. Inputs use text-base (16px) on mobile, sm:text-[13px] on desktop — + the established iOS auto-zoom fix. + +5. autoFocus on the username input only on pointer devices + (matchMedia '(hover: hover) and (pointer: fine)') — phones skip. + +6. Centered card on the workbench backdrop. Width max-w-md. + +7. Brand mark at the top of the card: a small "CM" 8x8 zinc-900 tile + (matches the nav brand mark) and the title "Sign in". + +8. Below the form: a small zinc-500 footer line: + "Forgot the password? Check the deployment's .env file for CM_AGENT_PASSWORD." + +9. While a transition is pending, disable both the passkey button and + the form. Use useTransition(). + +The component should be self-contained — Tailwind utility classes only, +no external deps beyond the imports listed. +``` + +- [ ] **Step 4: Save the returned file** + +Save the skill's output to `web/app/cm-auth/auth-form.tsx`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/app/cm-auth/page.tsx web/app/cm-auth/auth-form.tsx && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): /cm-auth login page with passkey + password options" +``` + +--- + +## Task 10: `/cm-passkeys` page shell + frontend-design passkey list + +**Files:** +- Create: `web/app/cm-passkeys/page.tsx` +- Create: `web/app/cm-passkeys/passkey-list.tsx` (frontend-design) + +- [ ] **Step 1: Create the directory** + +```bash +mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/cm-passkeys +``` + +- [ ] **Step 2: Write the Server Component shell** + +Create `web/app/cm-passkeys/page.tsx`: + +```typescript +import { requireSession } from "@/lib/auth"; +import { readPasskeys } from "@/lib/auth-store"; +import PasskeyList from "./passkey-list"; + +export default async function PasskeysPage() { + const session = await requireSession(); + const list = await readPasskeys(session.username); + // Strip the publicKey field — clients don't need it; reduces payload size. + const visible = list.map(({ publicKey: _pk, ...rest }) => rest); + return ; +} + +export const dynamic = "force-dynamic"; +``` + +- [ ] **Step 3: Invoke frontend-design for the list** + +Use the Skill tool with `skill="frontend-design:frontend-design"` and: + +``` +Generate a Next.js 15 + Tailwind v4 Client Component at +`web/app/cm-passkeys/passkey-list.tsx`. + +Visual identity: refined SaaS dashboard (matches accounts-table, +users-table) — white card on zinc-50, ring-1 zinc-200, rounded-2xl, +sans for chrome with mono only for the credential ID display. + +Props: + type Passkey = { + id: string; + counter: number; + transports: string[]; + name: string; + createdAt: string; + }; + type Props = { + initial: Passkey[]; + username: string; + }; + +Imports: + import { beginRegistration, finishRegistration, deletePasskey } from "@/app/auth-actions"; + import { startRegistration } from "@simplewebauthn/browser"; + import ConfirmDialog from "@/components/confirm-dialog"; + import FormDialogShell, { Field, inputClass } from "@/components/form-dialog-shell"; + import Toast, { type ToastMessage } from "@/components/toast"; + // useState, useTransition, useRouter from react/next + +Behavior: + +1. Page head similar to AccountsTable's PageHead: eyebrow "Settings", + heading "Passkeys", count of enrolled. No Refresh button (the list + is server-revalidated on enroll/delete). An "Add passkey" primary + button on the right (zinc-900 pill). + +2. Empty state: if initial.length === 0, a centered card with text + "No passkeys enrolled yet. Add one to sign in with Face ID, Touch + ID, or fingerprint on this device." + +3. List of passkeys: each row in a rounded-xl bg-white ring-1 + zinc-200/60 card. Show: device name (font-semibold), small zinc-500 + line "Added ", and an x button on the right that + opens a ConfirmDialog "Remove passkey 'X'?". + +4. "Add passkey" button opens a FormDialogShell with: + - One field: "Device name" (e.g., "iPhone 15"), required. + - Submit calls beginRegistration() → startRegistration() → + finishRegistration({ response, deviceName }). + - On { ok: true }: dialog closes, success toast "Passkey added". + - On error: inline error in the dialog. + - autoFocus only on pointer devices (matchMedia same as create-account-dialog.tsx). + +5. Remove flow: ConfirmDialog → click confirm → deletePasskey(id) → + on success, success toast "Passkey removed". + +6. Mobile responsive — passkey list cards stack with full width. + +The component imports ConfirmDialog from @/components/confirm-dialog, +FormDialogShell from @/components/form-dialog-shell, and Toast from +@/components/toast (all already exist in the codebase). Tailwind v4 +utility classes only, no other external deps. +``` + +- [ ] **Step 4: Save the returned file** + +Save to `web/app/cm-passkeys/passkey-list.tsx`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/yiekheng/projects/cm_bot_v2 && \ +git add web/app/cm-passkeys/page.tsx web/app/cm-passkeys/passkey-list.tsx && \ +git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ + commit -m "feat(web): /cm-passkeys settings page for passkey enroll/remove" +``` + +--- + +## Task 11: Nav account menu (modify nav.tsx via frontend-design) + +**Files:** +- Modify: `web/components/nav.tsx` + +The current nav has the Accounts/Users tab pills. Add an account menu on the right that shows the operator's username with a dropdown for "Passkey settings" and "Sign out". + +- [ ] **Step 1: Read current nav for context** + +```bash +cat /home/yiekheng/projects/cm_bot_v2/web/components/nav.tsx +``` + +This is the existing file the next step modifies. + +- [ ] **Step 2: Invoke frontend-design** + +Use the Skill tool with `skill="frontend-design:frontend-design"` and: + +``` +Modify `web/components/nav.tsx` to add an account menu next to the +existing Accounts/Users tab pills. + +Current file: see context paste below the brief. + +Visual identity: same as the rest of the SaaS dashboard. Account menu +should feel like a quiet, secondary affordance — not competing with +the tab pills. + +Requirements: + +1. Keep the existing brand mark (left) and tab pills (center/right). +2. Add a new account button to the FAR right (after the tab pills): + - Small button with: a 6x6 zinc-900 circle showing the first letter + of the username (uppercase), then the username text in zinc-700. + - Hover: bg-zinc-100. + - On click: open a small dropdown menu. +3. Dropdown menu (anchored under the account button): + - Two items: + a. "Passkey settings" → next/link to "/cm-passkeys". + b. "Sign out" → button that calls the `logout` Server Action from + "@/app/auth-actions". Logout already redirects to /cm-auth so + no client-side redirect needed. + - Closes on Esc, on outside-click, and on item-click. +4. Username comes from a new prop `username: string`. +5. Mobile: the username text hides below sm:; only the avatar + circle remains. Dropdown still works. +6. Keep `"use client"` and `usePathname()` exactly as today. +7. Account menu: vanilla useState + click-outside via a ref, no + external deps. + +Component context (current file): + +[paste the contents of web/components/nav.tsx here when invoking the skill] + +Output the COMPLETE rewritten nav.tsx file ready to drop in. Default +export Nav() unchanged in name and required props — but Nav now takes +a `username` prop. +``` + +- [ ] **Step 3: Update layout.tsx to pass `username` to Nav** + +The new nav requires a `username` prop. The layout is a Server Component, so it can read the session and pass it down: + +Modify `web/app/layout.tsx`: + +```typescript +import "./globals.css"; +import type { Metadata, Viewport } from "next"; +import Nav from "@/components/nav"; +import AutoRefresh from "@/components/auto-refresh"; +import { getSession } from "@/lib/auth"; + +export const metadata: Metadata = { + title: "CM Bot V2", + description: "CM Bot account and user dashboard", +}; + +export const viewport: Viewport = { + themeColor: "#18181b", + viewportFit: "cover", +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getSession(); + return ( + + + {session &&