1397 lines
46 KiB
Markdown
1397 lines
46 KiB
Markdown
# 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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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 <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**
|
||
|
||
```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 (
|
||
<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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# 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`).
|