46 KiB
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
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:
{
"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
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
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -H "^CM_AUTH_SECRET=" envs/*/.env.example
Expected: three lines, one per deployment.
- Step 3: Commit
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:
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):
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):
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:
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
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
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:
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<Session | null> {
const jar = await cookies();
const raw = jar.get(COOKIE_NAME)?.value;
if (!raw) return null;
try {
return await unsealData<Session>(raw, { password: secret() });
} catch {
return null;
}
}
export async function setSession(session: Session): Promise<void> {
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<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
}
export async function requireSession(): Promise<Session> {
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
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:
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<string, PasskeyRecord[]>;
// 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<void> = Promise.resolve();
async function readAll(): Promise<StoreShape> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<PasskeyRecord[]> {
const all = await readAll();
return all[username] ?? [];
}
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> {
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<boolean> {
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<void> {
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
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:
import "server-only";
import { headers } from "next/headers";
export type RpInfo = {
rpID: string;
origin: string;
rpName: string;
};
export async function getRpInfo(): Promise<RpInfo> {
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
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:
"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<ActionResult> {
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<void> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<boolean> {
const expectedUsername = process.env.CM_AGENT_ID ?? "";
if (!expectedUsername) return false;
const list = await readPasskeys(expectedUsername);
return list.length > 0;
}
- Step 2: Commit
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:
import { NextRequest, NextResponse } from "next/server";
import { unsealData } from "iron-session";
const COOKIE_NAME = "cm_auth";
const PUBLIC_PATHS = new Set<string>(["/cm-auth"]);
type SessionShape = {
username: string;
authenticatedAt: number;
};
async function isAuthenticated(req: NextRequest): Promise<boolean> {
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<SessionShape>(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
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
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:
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<SearchParams>;
}) {
// 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 <AuthForm passkeysAvailable={passkeysAvailable} next={next} />;
}
// 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
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
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:
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 <PasskeyList initial={visible} username={session.username} />;
}
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 <relative-time>", 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
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
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
usernameto 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:
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 (
<html lang="en">
<body className="min-h-screen bg-zinc-50 text-zinc-900 antialiased">
{session && <Nav username={session.username} />}
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
{children}
</main>
<AutoRefresh />
</body>
</html>
);
}
The Nav only renders when there's a session (the /cm-auth page renders inside the layout but without a session, so we hide the nav there).
- Step 4: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/components/nav.tsx web/app/layout.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): nav account menu with sign out + passkey settings link"
Task 12: AGENTS.md Auth subsection
Files:
-
Modify:
AGENTS.md -
Step 1: Add an Auth subsection
In AGENTS.md, find the ## Dev Tier (Local Development) heading. Insert a new ## Auth section immediately above it:
## Auth
- The Next.js dashboard (`cm-web-next`) gates every route except `/cm-auth` behind a session cookie.
- **Password sign-in** uses `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the deployment's `.env` (constant-time compare). No separate user table.
- **WebAuthn passkey** sign-in is the preferred path on devices with platform authenticators (Face ID, Touch ID, Android fingerprint). Enroll one at `/cm-passkeys` after the first password login.
- Session: signed `httpOnly` cookie (`cm_auth`), 30-day rolling. Requires `CM_AUTH_SECRET` env var (≥32 chars). Generate with `openssl rand -hex 32`.
- Passkey storage: `/data/auth/passkeys.json` inside the container, mounted from the `${CM_DEPLOY_NAME}-web-next-auth-data` named volume. Atomic writes; persists across container restarts and image rebuilds.
- "Forgot password" recovery: look at the deployment's `.env`. There's no email reset flow.
- Rotating `CM_AUTH_SECRET` invalidates all sessions (forces everyone to re-login).
- Step 2: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): document the auth model and passkey storage"
Task 13: Build + integration verification
Files: none modified.
This requires docker compose v2 on the deploy host plus a CM_AUTH_SECRET set in the live .env.
- Step 1: Set the secret in the live
.env
Generate and copy:
cd /home/yiekheng/projects/cm_bot_v2 && \
openssl rand -hex 32
Append the resulting line to the repo-root .env (or your envs/dev/.env):
CM_AUTH_SECRET=<the 64-char hex string from above>
- Step 2: Rebuild + bring up
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml down --remove-orphans && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next 2>&1 | tail -30 && \
bash scripts/dev.sh up
Expected: build succeeds (npm install resolves iron-session and @simplewebauthn/* against the registry). Stack comes up with mysql, api-server, web-view, web-next.
- Step 3: Cold-start redirect check
curl -s -o /dev/null -w "code=%{http_code}\nlocation=%{redirect_url}\n" -L=0 http://localhost:8010/
Expected: code=307 (or 308) with location=http://localhost:8010/cm-auth?next=%2F. A fresh browser would land on /cm-auth.
- Step 4: Password login (browser)
Open http://localhost:8010/ in a browser. Redirected to /cm-auth?next=%2F. Enter CM_AGENT_ID and CM_AGENT_PASSWORD from the dev .env. Submit. Browser redirects to /. Accounts table renders.
DevTools → Application → Cookies → cm_auth cookie present, httpOnly, secure only if NODE_ENV=production (in the dev override container it's set, so should be secure-flagged in prod compose only).
- Step 5: Wrong password
Sign out (account menu → Sign out — redirects to /cm-auth). Try wrong password. Inline red error appears. No cookie set. No redirect.
- Step 6: Passkey enrollment (Chrome desktop with Touch ID, or iPhone PWA)
Sign in with password. Click account menu → "Passkey settings". Page renders an empty state. Click "Add passkey". Dialog appears. Type "MacBook" (or your device name). Submit → Touch ID prompt → success toast "Passkey added" → dialog closes → row appears in the list.
Verify the JSON file exists:
sudo docker exec dev-cm-web-next cat /data/auth/passkeys.json
Expected: JSON with one entry under your CM_AGENT_ID key.
- Step 7: Passkey login
Sign out. /cm-auth now shows "Sign in with passkey" as the primary CTA (because passkeys exist for this username). Click → Touch ID → redirect to /.
- Step 8: Passkey persistence across rebuild
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml down && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next && \
bash scripts/dev.sh up
After up, the passkey JSON file still has the previously enrolled credential (volume persisted). Login via passkey still works.
- Step 9: Passkey removal
Sign in. /cm-passkeys. Click × on the device → ConfirmDialog → confirm → success toast. Row gone. JSON file no longer contains that credential.
- Step 10: Full unit suite (existing tests)
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper 2>&1 | tail -3
Expected: OK (B-auth doesn't change Python behavior; this is a regression check).
- Step 11: Constant-time compare manual check
grep -A 15 "constantTimeEqual" /home/yiekheng/projects/cm_bot_v2/web/app/auth-actions.ts
Expected: function uses Buffer.alloc to pad to equal length, calls timingSafeEqual on the padded buffers, then ANDs in a length check. No early-return on length mismatch (which would leak length). No string == comparison. (This is a code-review step — read it manually.)
- Step 12: Middleware coverage
While signed out:
# All these should redirect to /cm-auth:
curl -sIo /dev/null -w "/ → %{http_code} %{redirect_url}\n" http://localhost:8010/
curl -sIo /dev/null -w "/users → %{http_code} %{redirect_url}\n" http://localhost:8010/users/
curl -sIo /dev/null -w "/cm-passkeys → %{http_code} %{redirect_url}\n" http://localhost:8010/cm-passkeys
# But /cm-auth itself should NOT redirect:
curl -sIo /dev/null -w "/cm-auth → %{http_code}\n" http://localhost:8010/cm-auth
# Static assets should NOT be gated:
curl -sIo /dev/null -w "/manifest.webmanifest → %{http_code}\n" http://localhost:8010/manifest.webmanifest
curl -sIo /dev/null -w "/icon → %{http_code}\n" http://localhost:8010/icon
Expected: protected paths return 307/308 with redirect_url=...cm-auth?next=.... /cm-auth returns 200. Manifest and icon both return 200 (PWA install needs them reachable while logged out — they're in the matcher's negative lookahead).
Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
iron-session cookie session |
Task 4 |
CM_AUTH_SECRET env var documented |
Task 2, Task 12 |
| Constant-time password compare | Task 7 (constantTimeEqual) |
| WebAuthn registration flow (begin/finish) | Task 7 (beginRegistration, finishRegistration) |
| WebAuthn authentication flow (begin/finish) | Task 7 (beginAuthentication, finishAuthentication) |
| Passkey JSON store with atomic writes + write lock | Task 5 |
| Per-username keyed passkey storage | Task 5 (StoreShape = Record<string, PasskeyRecord[]>) |
/cm-auth login page |
Task 9 |
/cm-passkeys settings page |
Task 10 |
Middleware gating, only /cm-auth public |
Task 8 |
| Manifest/icon endpoints stay reachable | Task 8 (matcher) |
| Account menu in nav with sign out + settings link | Task 11 |
| Layout passes username to nav | Task 11 step 3 |
Docker volume web-next-auth-data |
Task 3 |
CM_AUTH_SECRET env templates |
Task 2 |
| AGENTS.md Auth subsection | Task 12 |
| Verification: cold start, password login, wrong password, enroll passkey, passkey login, persistence, remove, middleware coverage | Task 13 |
No gaps. Function and prop names consistent across tasks. The username prop on Nav is introduced in Task 11 and the layout updated in the same task to provide it, so no inter-task type drift. The Passkey shape consumed by passkey-list.tsx (Task 10) matches the projection done in cm-passkeys/page.tsx Server Component (drops publicKey).