# 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 &&