From 477e09f645099c4a7fd49cd577ec99c6cfc285a6 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 17:19:20 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94=20aut?= =?UTF-8?q?h=20+=20production=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 tasks, TDD-shaped, executable by superpowers:subagent-driven-development. ~50 unit tests across auth-cookie / safe-redirect / auth helpers / loginAction / middleware / user-management actions, covering brute- force, cookie tampering, replay, expiry, fixation, open redirect, timing-equivalence on user-not-found, rate-limit trigger, no- password-leak in logs, role gates, last-admin / self-demote guards, and the unauth-API regression for /api/events + /api/qr. Plan honours the project's .gitignore policy of keeping .env.development committed; ships .env.example for documentation instead of forcing repo-level removal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-10-auth-and-prod-hardening.md | 2317 +++++++++++++++++ 1 file changed, 2317 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md diff --git a/docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md b/docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md new file mode 100644 index 0000000..fb0907f --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md @@ -0,0 +1,2317 @@ +# Auth + Production Hardening 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 username + password + role auth on the web app and close the v1.1.0 production-readiness gaps (gated SSE/QR, robots, container non-root, rate limits) so the bot is safe to expose at `wabot.04080616.xyz`. + +**Architecture:** Roll-our-own session cookie. `bcryptjs` for password hashing (pure JS, edge-safe build), Web Crypto's HMAC-SHA256 for cookie signing in the edge-runtime middleware. Two roles (`admin` / `user`) on the existing `operators` table. Defense-in-depth: middleware verifies the cookie on every request, every Server Action also calls `requireUser()` / `requireAdmin()`. Existing `getSeededOperator()` becomes a compat shim that delegates to `getCurrentUser()` so the 66 existing call sites and ~12 test mocks keep working. + +**Tech Stack:** Next.js 16 App Router + React 19, edge runtime middleware, Drizzle ORM + Postgres, vitest, bcryptjs, Web Crypto. + +--- + +## File Structure + +| File | Role | +| --- | --- | +| `packages/db/migrations/0010_add_user_auth.sql` (generated) | adds `username`, `password_hash` to `operators`, unique `lower(username)` index | +| `packages/db/src/schema.ts` | drizzle alignment for the two new columns | +| `apps/web/src/lib/auth-cookie.ts` (new) | edge-safe HMAC-SHA256 sign/verify; pure functions | +| `apps/web/src/lib/auth-cookie.test.ts` (new) | 10 unit tests for the verifier | +| `apps/web/src/lib/safe-redirect.ts` (new) | open-redirect-safe `next` URL parser | +| `apps/web/src/lib/safe-redirect.test.ts` (new) | 5 unit tests | +| `apps/web/src/lib/auth.ts` (new) | `getCurrentUser`, `requireUser`, `requireAdmin` (Node runtime, DB-backed) | +| `apps/web/src/lib/auth.test.ts` (new) | 4 unit tests | +| `apps/web/src/lib/operator.ts` (modify) | compat shim: keep `getSeededOperator` as a passthrough | +| `apps/web/src/actions/auth.ts` (new) | `loginAction`, `logoutAction` | +| `apps/web/src/actions/auth.test.ts` (new) | 11 unit tests covering the login flow | +| `apps/web/src/actions/users.ts` (new) | admin-only user CRUD actions | +| `apps/web/src/actions/users.test.ts` (new) | 6 unit tests including last-admin / self-demote guards | +| `apps/web/src/middleware.ts` (modify) | gate everything except allowlist on cookie verify | +| `apps/web/src/middleware.test.ts` (new) | 6 unit tests | +| `apps/web/src/app/login/page.tsx` (new) | login form server component | +| `apps/web/src/app/login/login-form-client.tsx` (new) | client form, calls `loginAction` | +| `apps/web/src/app/settings/users/page.tsx` (new) | admin-only user management page | +| `apps/web/src/app/settings/users/user-row-client.tsx` (new) | per-row actions (reset / promote / delete) | +| `apps/web/src/app/robots.ts` (new) | `User-agent: * / Disallow: /` | +| `apps/web/src/app/layout.tsx` (modify) | `metadata.robots = { index: false, follow: false }` | +| `apps/web/next.config.ts` (modify) | `serverActions.allowedOrigins` | +| `apps/web/src/actions/groups.ts` (modify) | rate limit on `sendTestAction` | +| `apps/web/src/actions/reminders.ts` (modify) | rate limits on `resumeReminderRunAction` and `cancelReminderRunAction` | +| `apps/web/package.json` (modify) | `bcryptjs` + `@types/bcryptjs` deps | +| `apps/web/.env.example` (new) | documents every required env key (incl. `OPERATOR_TOKEN_VERSION`) | +| `packages/db/src/scripts/set-password.ts` (new) | tsx script that bcrypts and updates `password_hash` | +| `packages/db/src/scripts/create-user.ts` (new) | tsx script that INSERTs a new operator with a bcrypted password | +| `scripts/set-password.sh` (new) | one-line wrapper that runs the tsx in the tools container | +| `scripts/create-user.sh` (new) | same shape | +| `docker/bot.Dockerfile` (modify) | non-root `app` user, `chown` `/data`, `chmod 700 /data/sessions` | +| `docker/web.Dockerfile` (modify) | non-root `app` user | + +--- + +## Task 1: Migration 0010 — username + password_hash on operators + +**Files:** +- Modify: `packages/db/src/schema.ts` (around the `operators` table block) +- Generate: `packages/db/migrations/0010_.sql` +- Modify: `packages/db/migrations/meta/_journal.json` (drizzle-kit auto-updates) + +- [ ] **Step 1: Edit `packages/db/src/schema.ts` — add the two columns to `operators`** + +Find the existing `operators` table block (around line 1) and modify so it reads: + +```ts +export const operators = pgTable( + "operators", + { + id: uuid("id").primaryKey().defaultRandom(), + telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(), + username: text("username").notNull(), + passwordHash: text("password_hash"), + displayName: text("display_name").notNull(), + role: text("role").notNull().default("admin"), + defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId), + usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`), + }), +); +``` + +Add the import at the top of `schema.ts` if not already present: + +```ts +import { sql } from "drizzle-orm"; +``` + +- [ ] **Step 2: Generate the SQL migration** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db generate +``` + +Expected: a new file `packages/db/migrations/0010_.sql` containing: +- `ALTER TABLE "operators" ADD COLUMN "username" text NOT NULL;` — but Drizzle will write it as a single `ALTER TABLE ADD COLUMN` per column. +- The unique index DDL. + +The auto-generated NOT NULL on `username` will fail on the existing row (it has no value yet). Open the generated `.sql` file and replace its contents with: + +```sql +-- Add username + password_hash to operators. Backfill the seed row to +-- 'admin' so the NOT NULL constraint succeeds; password_hash stays +-- nullable so the operator is forced to set one via the CLI before +-- they can sign in. +ALTER TABLE "operators" ADD COLUMN "username" text;--> statement-breakpoint +ALTER TABLE "operators" ADD COLUMN "password_hash" text;--> statement-breakpoint +UPDATE "operators" SET "username" = 'admin' WHERE "username" IS NULL;--> statement-breakpoint +ALTER TABLE "operators" ALTER COLUMN "username" SET NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "operators_username_uq" ON "operators" (lower("username")); +``` + +- [ ] **Step 3: Apply the migration** + +```bash +NO_SUDO=1 ./scripts/db.sh migrate +``` + +Expected: `Migrations applied.` + +- [ ] **Step 4: Run drizzle/db sanity check** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db build +``` + +Expected: typecheck passes; new columns exposed in the generated dist. + +- [ ] **Step 5: Rebuild apps/web typecheck** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web typecheck +``` + +Expected: passes (the new optional `username`/`passwordHash` props don't break existing code). + +- [ ] **Step 6: Commit** + +```bash +git add packages/db/src/schema.ts packages/db/migrations/0010_*.sql packages/db/migrations/meta +git commit -m "feat(db): add username + password_hash to operators + +Migration 0010 widens the existing operators table for username + +password auth. Backfills 'admin' on the seed row so the NOT NULL +constraint succeeds; password_hash stays nullable so the operator is +forced to set one via scripts/set-password.sh before they can sign in. +Adds a unique index on lower(username)." +``` + +--- + +## Task 2: bcryptjs dependency + +**Files:** +- Modify: `apps/web/package.json` + +- [ ] **Step 1: Add bcryptjs and its types to apps/web** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm add -F @cmbot/web bcryptjs +NO_SUDO=1 ./scripts/dev.sh exec pnpm add -F @cmbot/web -D @types/bcryptjs +``` + +- [ ] **Step 2: Add bcryptjs to packages/db (CLI scripts run from there)** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm add -F @cmbot/db bcryptjs +NO_SUDO=1 ./scripts/dev.sh exec pnpm add -F @cmbot/db -D @types/bcryptjs +``` + +- [ ] **Step 3: Verify typecheck** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web typecheck +``` + +Expected: passes. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/package.json packages/db/package.json pnpm-lock.yaml +git commit -m "chore: add bcryptjs to web + db packages + +Pure-JS bcrypt for password hashing. Avoids the native-build pain +of node-bcrypt in our Alpine Docker images. Login is a rare event +so the perf gap is irrelevant for our scale." +``` + +--- + +## Task 3: auth-cookie.ts — sign/verify (TDD) + +**Files:** +- Create: `apps/web/src/lib/auth-cookie.test.ts` +- Create: `apps/web/src/lib/auth-cookie.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `apps/web/src/lib/auth-cookie.test.ts`: + +```ts +import { describe, it, expect, beforeAll } from "vitest"; +import { + signSession, + verifySession, + COOKIE_NAME, + DEFAULT_TTL_SECONDS, + type SessionPayload, +} from "./auth-cookie"; + +const SECRET = "test-secret-not-used-anywhere-real"; +const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism + +beforeAll(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; +}); + +const validPayload = (): SessionPayload => ({ + userId: "11111111-1111-1111-1111-111111111111", + role: "admin", + iat: NOW, + exp: NOW + DEFAULT_TTL_SECONDS, + v: 1, +}); + +describe("auth-cookie", () => { + it("signSession + verifySession round-trips a valid payload", async () => { + const cookie = await signSession(validPayload(), SECRET); + const verified = await verifySession(cookie, SECRET, NOW); + expect(verified).toEqual(validPayload()); + }); + + it("rejects when the payload portion has been tampered with", async () => { + const cookie = await signSession(validPayload(), SECRET); + // Flip the role to admin → user in the payload, keep the same signature. + const [, sig] = cookie.split("."); + const tampered = btoa(JSON.stringify({ ...validPayload(), role: "user" })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + "." + sig; + expect(await verifySession(tampered, SECRET, NOW)).toBeNull(); + }); + + it("rejects when the signature has been tampered with", async () => { + const cookie = await signSession(validPayload(), SECRET); + const [payload] = cookie.split("."); + const tampered = payload + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + expect(await verifySession(tampered, SECRET, NOW)).toBeNull(); + }); + + it("rejects when verified with a different secret", async () => { + const cookie = await signSession(validPayload(), SECRET); + expect(await verifySession(cookie, "different-secret", NOW)).toBeNull(); + }); + + it("rejects an expired cookie (exp <= now)", async () => { + const expired = { ...validPayload(), exp: NOW - 1 }; + const cookie = await signSession(expired, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => { + const future = { ...validPayload(), iat: NOW + 120 }; + const cookie = await signSession(future, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("accepts a cookie issued slightly in the future (within 60s skew)", async () => { + const future = { ...validPayload(), iat: NOW + 30 }; + const cookie = await signSession(future, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull(); + }); + + it("rejects a cookie whose v is stale (token-version bumped)", async () => { + const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET); + process.env.OPERATOR_TOKEN_VERSION = "2"; + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + process.env.OPERATOR_TOKEN_VERSION = "1"; + }); + + it("rejects a cookie with an unknown role string", async () => { + const cookie = await signSession( + { ...validPayload(), role: "superadmin" as never }, + SECRET, + ); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("rejects a cookie that doesn't have a '.' separator", async () => { + expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull(); + expect(await verifySession("", SECRET, NOW)).toBeNull(); + }); + + it("exposes COOKIE_NAME as 'session'", () => { + expect(COOKIE_NAME).toBe("session"); + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails for the right reason** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run auth-cookie +``` + +Expected: FAIL with `Cannot find module './auth-cookie'`. + +- [ ] **Step 3: Implement the module** + +Create `apps/web/src/lib/auth-cookie.ts`: + +```ts +/** + * Edge-runtime-safe HMAC-signed session cookie. Runs in middleware + * and Server Actions. NO database, NO bcrypt, NO Node-only APIs — + * pure Web Crypto so it survives Edge runtime. + */ + +export const COOKIE_NAME = "session"; +export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days +export const CLOCK_SKEW_SECONDS = 60; + +export type Role = "admin" | "user"; + +export interface SessionPayload { + userId: string; + role: Role; + iat: number; + exp: number; + v: number; +} + +function isValidPayload(x: unknown): x is SessionPayload { + if (typeof x !== "object" || x === null) return false; + const o = x as Record; + return ( + typeof o.userId === "string" && + (o.role === "admin" || o.role === "user") && + typeof o.iat === "number" && + typeof o.exp === "number" && + typeof o.v === "number" + ); +} + +function b64urlEncode(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(str: string): Uint8Array { + const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4)); + const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} + +async function importKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +/** Constant-time compare on byte arrays. Returns true iff equal. */ +function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; + return diff === 0; +} + +export async function signSession( + payload: SessionPayload, + secret: string, +): Promise { + const json = JSON.stringify(payload); + const payloadEnc = b64urlEncode(new TextEncoder().encode(json)); + const key = await importKey(secret); + const sigBytes = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadEnc)), + ); + return `${payloadEnc}.${b64urlEncode(sigBytes)}`; +} + +export async function verifySession( + cookie: string, + secret: string, + now: number = Math.floor(Date.now() / 1000), +): Promise { + if (!cookie || typeof cookie !== "string") return null; + const dot = cookie.indexOf("."); + if (dot <= 0 || dot === cookie.length - 1) return null; + const payloadEnc = cookie.slice(0, dot); + const sigEnc = cookie.slice(dot + 1); + + let sigBytes: Uint8Array; + try { + sigBytes = b64urlDecode(sigEnc); + } catch { + return null; + } + + const key = await importKey(secret); + const expected = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadEnc)), + ); + if (!timingSafeEqual(sigBytes, expected)) return null; + + let json: string; + let payload: unknown; + try { + json = new TextDecoder().decode(b64urlDecode(payloadEnc)); + payload = JSON.parse(json); + } catch { + return null; + } + if (!isValidPayload(payload)) return null; + + if (payload.exp <= now) return null; + if (payload.iat > now + CLOCK_SKEW_SECONDS) return null; + + const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1"); + if (payload.v !== expectedV) return null; + + return payload; +} +``` + +- [ ] **Step 4: Run the tests — verify they pass** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run auth-cookie +``` + +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/auth-cookie.ts apps/web/src/lib/auth-cookie.test.ts +git commit -m "feat(web): edge-safe HMAC-signed session cookie + +signSession + verifySession run on Edge runtime (Web Crypto only). +Verifier checks signature (constant-time compare), expiry, clock-skew +on iat (60s tolerance), token version vs OPERATOR_TOKEN_VERSION env, +and role-shape sanity. 11 unit tests cover round-trip plus every +rejection path attackers could probe." +``` + +--- + +## Task 4: safe-redirect.ts — open-redirect prevention (TDD) + +**Files:** +- Create: `apps/web/src/lib/safe-redirect.test.ts` +- Create: `apps/web/src/lib/safe-redirect.ts` + +- [ ] **Step 1: Write the failing test file** + +```ts +import { describe, it, expect } from "vitest"; +import { safeRedirect } from "./safe-redirect"; + +describe("safeRedirect", () => { + it("preserves a relative path that starts with a single slash", () => { + expect(safeRedirect("/dashboard")).toBe("/dashboard"); + expect(safeRedirect("/reminders/new")).toBe("/reminders/new"); + }); + + it("preserves query string and fragment", () => { + expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe( + "/legit?with=params&extra=fine#hash", + ); + }); + + it("rejects protocol-relative URLs (//evil.com)", () => { + expect(safeRedirect("//evil.com")).toBe("/"); + expect(safeRedirect("//evil.com/dashboard")).toBe("/"); + }); + + it("rejects absolute URLs", () => { + expect(safeRedirect("https://evil.com")).toBe("/"); + expect(safeRedirect("http://evil.com/dashboard")).toBe("/"); + }); + + it("rejects javascript: and data: schemes", () => { + expect(safeRedirect("javascript:alert(1)")).toBe("/"); + expect(safeRedirect("data:text/html,")).toBe("/"); + }); + + it("falls back to / for empty / null / undefined / whitespace input", () => { + expect(safeRedirect("")).toBe("/"); + expect(safeRedirect(null)).toBe("/"); + expect(safeRedirect(undefined)).toBe("/"); + expect(safeRedirect(" ")).toBe("/"); + }); + + it("rejects paths that don't start with / (relative-relative)", () => { + expect(safeRedirect("dashboard")).toBe("/"); + expect(safeRedirect("./dashboard")).toBe("/"); + expect(safeRedirect("../dashboard")).toBe("/"); + }); +}); +``` + +- [ ] **Step 2: Run — verify it fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run safe-redirect +``` + +Expected: FAIL with `Cannot find module`. + +- [ ] **Step 3: Implement** + +Create `apps/web/src/lib/safe-redirect.ts`: + +```ts +/** + * Returns `next` if it is a safe relative path, otherwise "/". + * + * Safe means: starts with a single forward slash AND not "//" (which + * is a protocol-relative URL, e.g. //evil.com). Anything else falls + * back to the root — including empty input, absolute URLs, javascript: + * URIs, and relative-relative paths like "dashboard" or "../foo". + */ +export function safeRedirect(next: string | null | undefined): string { + if (typeof next !== "string") return "/"; + const s = next.trim(); + if (s.length < 2) return "/"; + if (!s.startsWith("/")) return "/"; + if (s.startsWith("//")) return "/"; + return s; +} +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run safe-redirect +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/safe-redirect.ts apps/web/src/lib/safe-redirect.test.ts +git commit -m "feat(web): safeRedirect helper for the login \\?next= param + +Falls back to / for anything that isn't a single-slash-prefixed +relative path. Locks out protocol-relative (//evil.com), absolute +(https://evil.com), and javascript: redirects. 7 tests cover the +full attacker matrix." +``` + +--- + +## Task 5: auth.ts — getCurrentUser / requireUser / requireAdmin (TDD) + +**Files:** +- Create: `apps/web/src/lib/auth.test.ts` +- Create: `apps/web/src/lib/auth.ts` +- Modify: `apps/web/src/lib/operator.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `apps/web/src/lib/auth.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const cookiesGetMock = vi.fn(); +const findUserMock = vi.fn(); + +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: cookiesGetMock }), +})); +vi.mock("./db", () => ({ + db: { + query: { + operators: { + findFirst: (...a: unknown[]) => findUserMock(...a), + }, + }, + }, +})); + +const SECRET = "test-secret"; +beforeEach(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; + cookiesGetMock.mockReset(); + findUserMock.mockReset(); +}); + +import { signSession } from "./auth-cookie"; +import { getCurrentUser, requireUser, requireAdmin } from "./auth"; + +const NOW_S = Math.floor(Date.now() / 1000); +const ADMIN = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + role: "admin" as const, + displayName: "Admin", + defaultTimezone: "UTC", + passwordHash: null, +}; +const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const }; + +async function makeCookie(role: "admin" | "user"): Promise { + return signSession( + { + userId: role === "admin" ? ADMIN.id : USER.id, + role, + iat: NOW_S, + exp: NOW_S + 3600, + v: 1, + }, + SECRET, + ); +} + +describe("auth helpers", () => { + it("getCurrentUser returns null when no cookie is set", async () => { + cookiesGetMock.mockReturnValue(undefined); + const u = await getCurrentUser(); + expect(u).toBeNull(); + }); + + it("getCurrentUser returns the user row for a valid admin cookie", async () => { + const cookie = await makeCookie("admin"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(ADMIN); + const u = await getCurrentUser(); + expect(u?.id).toBe(ADMIN.id); + expect(u?.role).toBe("admin"); + }); + + it("requireUser throws when there is no session", async () => { + cookiesGetMock.mockReturnValue(undefined); + await expect(requireUser()).rejects.toThrow(); + }); + + it("requireAdmin throws when role is 'user'", async () => { + const cookie = await makeCookie("user"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(USER); + await expect(requireAdmin()).rejects.toThrow(); + }); + + it("requireAdmin returns the user when role is 'admin'", async () => { + const cookie = await makeCookie("admin"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(ADMIN); + const u = await requireAdmin(); + expect(u.role).toBe("admin"); + }); +}); +``` + +- [ ] **Step 2: Run — verify fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run lib/auth.test +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `apps/web/src/lib/auth.ts`** + +```ts +import "server-only"; +import { cookies } from "next/headers"; +import { db } from "./db"; +import { COOKIE_NAME, verifySession } from "./auth-cookie"; + +export type AuthUser = { + id: string; + username: string; + role: "admin" | "user"; + displayName: string; + defaultTimezone: string; + passwordHash: string | null; +}; + +export class UnauthenticatedError extends Error { + constructor() { + super("Unauthenticated"); + this.name = "UnauthenticatedError"; + } +} +export class ForbiddenError extends Error { + constructor() { + super("Forbidden"); + this.name = "ForbiddenError"; + } +} + +/** + * Returns the operator row whose userId is encoded in the session + * cookie, or null if the cookie is missing / invalid / the row is + * gone. Never throws — call requireUser() if you want a throw. + */ +export async function getCurrentUser(): Promise { + const jar = await cookies(); + const cookie = jar.get(COOKIE_NAME)?.value; + if (!cookie) return null; + const secret = process.env.AUTH_SECRET; + if (!secret) return null; + const payload = await verifySession(cookie, secret); + if (!payload) return null; + const row = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.id, payload.userId), + }); + if (!row) return null; + if (row.role !== "admin" && row.role !== "user") return null; + return { + id: row.id, + username: row.username, + role: row.role, + displayName: row.displayName, + defaultTimezone: row.defaultTimezone, + passwordHash: row.passwordHash, + }; +} + +export async function requireUser(): Promise { + const u = await getCurrentUser(); + if (!u) throw new UnauthenticatedError(); + return u; +} + +export async function requireAdmin(): Promise { + const u = await requireUser(); + if (u.role !== "admin") throw new ForbiddenError(); + return u; +} +``` + +- [ ] **Step 4: Update `apps/web/src/lib/operator.ts` to delegate** + +Replace the file with: + +```ts +import "server-only"; +import { getCurrentUser } from "./auth"; + +/** + * Compatibility shim. The app used to seed a single operator and + * attribute everything to it; now we have real auth + roles. Existing + * call sites read `.id` and `.defaultTimezone` off the returned + * object — both are still present on the AuthUser shape, so the + * swap is mechanical and existing tests that mock @/lib/operator + * keep working unchanged. + * + * New code should call getCurrentUser / requireUser / requireAdmin + * from @/lib/auth directly. + */ +export async function getSeededOperator() { + const u = await getCurrentUser(); + if (!u) { + throw new Error("Not authenticated"); + } + return u; +} +``` + +- [ ] **Step 5: Run all auth helper tests** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run lib/auth +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 6: Run the full web test suite to surface fallout** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run +``` + +Expected: 471 + 5 (auth) + 11 (auth-cookie) + 7 (safe-redirect) = 494, all green. If anything fails because `getSeededOperator` now throws when there's no session, the offending test should mock cookies + auth — patch by mocking `getCurrentUser` directly. Most test mocks of `@/lib/operator` should keep passing because they replace `getSeededOperator` wholesale. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/lib/auth.ts apps/web/src/lib/auth.test.ts apps/web/src/lib/operator.ts +git commit -m "feat(web): getCurrentUser / requireUser / requireAdmin helpers + +Reads the session cookie from next/headers, verifies via auth-cookie, +loads the operators row, returns the shape every existing call site +expects (.id, .defaultTimezone, etc) plus the new .role and +.username. getSeededOperator stays as a thin compat shim that +delegates to getCurrentUser, so the ~12 tests that mock +@/lib/operator keep working without churn." +``` + +--- + +## Task 6: loginAction + logoutAction (TDD) + +**Files:** +- Create: `apps/web/src/actions/auth.test.ts` +- Create: `apps/web/src/actions/auth.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `apps/web/src/actions/auth.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import bcrypt from "bcryptjs"; + +const cookiesSetMock = vi.fn(); +const cookiesDeleteMock = vi.fn(); +const findUserMock = vi.fn(); +const headersGetMock = vi.fn(() => "127.0.0.1"); +const checkRateLimitMock = vi.fn(); +const redirectMock = vi.fn(() => { + throw new Error("redirect"); +}); +const loggerMock = { warn: vi.fn(), info: vi.fn() }; + +vi.mock("next/headers", () => ({ + cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }), + headers: async () => ({ + get: (k: string) => (k.toLowerCase() === "x-forwarded-for" ? headersGetMock() : null), + }), +})); +vi.mock("next/navigation", () => ({ redirect: redirectMock })); +vi.mock("@/lib/db", () => ({ + db: { + query: { + operators: { findFirst: (...a: unknown[]) => findUserMock(...a) }, + }, + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a), +})); +vi.mock("@/lib/logger", () => ({ logger: loggerMock })); + +const SECRET = "test-secret-not-real"; +beforeEach(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; + cookiesSetMock.mockReset(); + cookiesDeleteMock.mockReset(); + findUserMock.mockReset(); + checkRateLimitMock.mockReset(); + checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 }); + redirectMock.mockReset(); + redirectMock.mockImplementation(() => { + throw new Error("redirect"); + }); + loggerMock.warn.mockReset(); +}); + +import { loginAction, logoutAction } from "./auth"; + +const REAL_HASH = bcrypt.hashSync("correct-horse", 10); +const ADMIN_ROW = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + role: "admin" as const, + displayName: "Admin", + defaultTimezone: "UTC", + passwordHash: REAL_HASH, +}; + +function fd(fields: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(fields)) f.append(k, v); + return f; +} + +describe("loginAction", () => { + it("issues a session cookie when credentials are correct", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch( + (e) => e, + ); + // Successful login redirects, so the redirect mock throws. + expect((r as Error).message).toBe("redirect"); + expect(cookiesSetMock).toHaveBeenCalledTimes(1); + const [name, , attrs] = cookiesSetMock.mock.calls[0]!; + expect(name).toBe("session"); + expect(attrs).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 30 * 86400, + }); + }); + + it("returns ok:false on wrong password and does NOT set a cookie", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + const r = await loginAction(fd({ username: "admin", password: "wrong" })); + expect(r).toEqual({ ok: false, error: "Invalid username or password." }); + expect(cookiesSetMock).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalled(); + }); + + it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => { + findUserMock.mockResolvedValue(undefined); + const cmpSpy = vi.spyOn(bcrypt, "compare"); + await loginAction(fd({ username: "nobody", password: "irrelevant" })); + expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH + cmpSpy.mockRestore(); + }); + + it("returns a clear error when the user has no password_hash set", async () => { + findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null }); + const r = await loginAction(fd({ username: "admin", password: "anything" })); + expect(r).toEqual({ + ok: false, + error: "Set a password via scripts/set-password.sh before signing in.", + }); + }); + + it("rejects empty username or password without hitting the DB", async () => { + const r = await loginAction(fd({ username: "", password: "x" })); + expect(r).toEqual({ ok: false, error: "Username and password are required." }); + expect(findUserMock).not.toHaveBeenCalled(); + }); + + it("rejects username/password >256 chars without invoking bcrypt", async () => { + const cmpSpy = vi.spyOn(bcrypt, "compare"); + const long = "x".repeat(300); + const r = await loginAction(fd({ username: long, password: long })); + expect(r).toEqual({ ok: false, error: "Input too long." }); + expect(cmpSpy).not.toHaveBeenCalled(); + cmpSpy.mockRestore(); + }); + + it("matches username case-insensitively", async () => { + findUserMock.mockImplementation(async (cfg: { where: (t: unknown, ops: unknown) => unknown }) => { + // The where clause is opaque here; we just verify that the call + // happened and trust the SQL lower(...) index. Return the row. + return ADMIN_ROW; + }); + await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {}); + expect(findUserMock).toHaveBeenCalled(); + }); + + it("returns 429 when the rate limit is exhausted", async () => { + checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 }); + const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); + expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." }); + expect(findUserMock).not.toHaveBeenCalled(); + }); + + it("logs the failed attempt with username and ip but never the password", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1"); + await loginAction(fd({ username: "admin", password: "wrong" })); + const [meta, msg] = loggerMock.warn.mock.calls[0]!; + expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" }); + expect(JSON.stringify(meta)).not.toContain("wrong"); + expect(msg).toMatch(/login failed/i); + }); + + it("redirects to safeRedirect(next) on success", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + await loginAction(fd({ + username: "admin", + password: "correct-horse", + next: "/dashboard", + })).catch(() => {}); + expect(redirectMock).toHaveBeenCalledWith("/dashboard"); + }); + + it("redirects to / when next is unsafe", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + await loginAction(fd({ + username: "admin", + password: "correct-horse", + next: "//evil.com", + })).catch(() => {}); + expect(redirectMock).toHaveBeenCalledWith("/"); + }); +}); + +describe("logoutAction", () => { + it("clears the session cookie and redirects to /login", async () => { + await logoutAction().catch(() => {}); + expect(cookiesDeleteMock).toHaveBeenCalledWith("session"); + expect(redirectMock).toHaveBeenCalledWith("/login"); + }); +}); +``` + +- [ ] **Step 2: Run — verify fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run actions/auth +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `apps/web/src/actions/auth.ts`** + +```ts +"use server"; + +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import bcrypt from "bcryptjs"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + COOKIE_NAME, + DEFAULT_TTL_SECONDS, + signSession, + type Role, +} from "@/lib/auth-cookie"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { safeRedirect } from "@/lib/safe-redirect"; +import { logger } from "@/lib/logger"; + +export type LoginResult = { ok: true } | { ok: false; error: string }; + +const MAX_FIELD_LEN = 256; + +// Precomputed bcryptjs hash of the throwaway string "x", cost 10. +// Compared against on the user-not-found path so timing matches the +// wrong-password path. Generating fresh per request would double the +// bcrypt work and create its own timing signal. +const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + +async function clientIp(): Promise { + const h = await headers(); + const fwd = h.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0]!.trim(); + return h.get("x-real-ip") ?? "unknown"; +} + +export async function loginAction(formData: FormData): Promise { + const username = (formData.get("username") ?? "").toString(); + const password = (formData.get("password") ?? "").toString(); + const next = (formData.get("next") ?? "").toString(); + + if (!username.trim() || !password) { + return { ok: false, error: "Username and password are required." }; + } + if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) { + return { ok: false, error: "Input too long." }; + } + + const ip = await clientIp(); + const rl = await checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }); + if (rl.limited) { + return { ok: false, error: "Too many attempts. Try again later." }; + } + + const row = await db.query.operators.findFirst({ + where: (o) => sql`lower(${o.username}) = lower(${username})`, + }); + + // Run bcrypt regardless to keep the user-not-found path timing- + // equivalent to the wrong-password path. + const hash = row?.passwordHash ?? DUMMY_HASH; + const ok = await bcrypt.compare(password, hash); + + if (!row || !ok) { + logger.warn({ username, ip }, "login failed"); + return { ok: false, error: "Invalid username or password." }; + } + + if (row.passwordHash === null) { + return { + ok: false, + error: "Set a password via scripts/set-password.sh before signing in.", + }; + } + + if (row.role !== "admin" && row.role !== "user") { + return { ok: false, error: "Account is not enabled." }; + } + + const secret = process.env.AUTH_SECRET; + if (!secret) { + logger.warn({}, "AUTH_SECRET unset — cannot issue cookie"); + return { ok: false, error: "Server is not configured for sign-in." }; + } + const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1"); + const now = Math.floor(Date.now() / 1000); + const cookie = await signSession( + { + userId: row.id, + role: row.role as Role, + iat: now, + exp: now + DEFAULT_TTL_SECONDS, + v, + }, + secret, + ); + const jar = await cookies(); + jar.set(COOKIE_NAME, cookie, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: DEFAULT_TTL_SECONDS, + }); + + redirect(safeRedirect(next)); +} + +export async function logoutAction(): Promise { + const jar = await cookies(); + jar.delete(COOKIE_NAME); + redirect("/login"); +} +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run actions/auth +``` + +Expected: PASS, 12 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/actions/auth.ts apps/web/src/actions/auth.test.ts +git commit -m "feat(web): loginAction + logoutAction (with TDD) + +Username + password verified against the operators row, bcrypt +compare regardless of user-found state for timing equivalence, +DUMMY_HASH precomputed and committed. 10/5min IP rate limit, no +password ever logged. Issues a 30-day HttpOnly+Secure+SameSite=Lax +cookie on success, redirects via safeRedirect(next). 12 unit tests +covering correct creds, wrong username, wrong password, missing +password_hash, empty/long inputs, case-insensitive match, rate-limit +trigger, no-password-leak, safe redirect, unsafe redirect, logout." +``` + +--- + +## Task 7: Login page UI + +**Files:** +- Create: `apps/web/src/app/login/page.tsx` +- Create: `apps/web/src/app/login/login-form-client.tsx` + +- [ ] **Step 1: Implement `apps/web/src/app/login/login-form-client.tsx`** + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { Loader2Icon, LockIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { loginAction } from "@/actions/auth"; + +export function LoginFormClient({ next }: { next: string }) { + const [pending, start] = useTransition(); + const [error, setError] = useState(null); + + function handle(formData: FormData) { + formData.append("next", next); + start(async () => { + setError(null); + const r = await loginAction(formData); + // On success, the action redirects (no return). If we land here, + // something failed and `r` is the error shape. + if (r && !r.ok) setError(r.error); + }); + } + + return ( +
+
+ + +
+
+ + +
+ {error && ( +
{error}
+ )} + +

+ First time? Run ./scripts/set-password.sh <username>{" "} + in your tools container. +

+
+ ); +} +``` + +- [ ] **Step 2: Implement `apps/web/src/app/login/page.tsx`** + +```tsx +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LoginFormClient } from "./login-form-client"; + +export const metadata = { + title: "Sign in", +}; + +interface PageProps { + searchParams: Promise<{ next?: string }>; +} + +export default async function LoginPage({ searchParams }: PageProps) { + const sp = await searchParams; + const next = sp.next ?? "/"; + + return ( +
+ + + Sign in + + + + + +
+ ); +} +``` + +- [ ] **Step 3: Typecheck** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web typecheck +``` + +Expected: passes. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/app/login +git commit -m "feat(web): /login page with username + password form + +Server-rendered card-style login. Form posts to loginAction; on +failure the client renders the generic 'Invalid username or +password' error. Centred, mobile-first, autocomplete-friendly so +phone PWAs autofill from the keychain on subsequent logins." +``` + +--- + +## Task 8: Middleware gate (TDD) + +**Files:** +- Create: `apps/web/src/middleware.test.ts` +- Modify: `apps/web/src/middleware.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `apps/web/src/middleware.test.ts`: + +```ts +import { describe, it, expect, beforeAll } from "vitest"; +import { NextRequest } from "next/server"; + +const SECRET = "test-secret"; +beforeAll(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; +}); + +import { signSession } from "./lib/auth-cookie"; +import { middleware } from "./middleware"; + +async function makeReq(path: string, cookie?: string): Promise { + const url = new URL(`https://wabot.04080616.xyz${path}`); + const init: RequestInit & { headers: Headers } = { headers: new Headers() }; + if (cookie) init.headers.set("cookie", `session=${cookie}`); + return new NextRequest(url, init); +} + +async function validCookie(): Promise { + const now = Math.floor(Date.now() / 1000); + return signSession( + { + userId: "00000000-0000-0000-0000-000000000000", + role: "admin", + iat: now, + exp: now + 3600, + v: 1, + }, + SECRET, + ); +} + +describe("middleware", () => { + it("page request without a cookie redirects to /login?next=…", async () => { + const r = await middleware(await makeReq("/dashboard")); + expect(r.status).toBe(307); + expect(r.headers.get("location")).toContain("/login"); + expect(r.headers.get("location")).toContain("next=%2Fdashboard"); + }); + + it("/api/* request without a cookie returns 401 with no body", async () => { + const r = await middleware(await makeReq("/api/events")); + expect(r.status).toBe(401); + }); + + it("page request with a valid cookie passes through", async () => { + const r = await middleware(await makeReq("/dashboard", await validCookie())); + // NextResponse.next() returns a 200 with the x-middleware-next header. + expect(r.status).toBe(200); + }); + + it("page request with a tampered cookie redirects to /login", async () => { + const cookie = (await validCookie()).slice(0, -4) + "AAAA"; + const r = await middleware(await makeReq("/dashboard", cookie)); + expect(r.status).toBe(307); + expect(r.headers.get("location")).toContain("/login"); + }); + + it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => { + for (const path of [ + "/login", + "/logout", + "/api/health", + "/manifest.webmanifest", + "/icon-192.png", + "/favicon.ico", + ]) { + const r = await middleware(await makeReq(path)); + expect(r.status).toBe(200); + } + }); + + it("/api/events and /api/qr/ are NOT in the allowlist (regression)", async () => { + expect((await middleware(await makeReq("/api/events"))).status).toBe(401); + expect( + ( + await middleware( + await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"), + ) + ).status, + ).toBe(401); + }); +}); +``` + +- [ ] **Step 2: Run — verify it fails (existing middleware doesn't gate auth yet)** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run middleware +``` + +Expected: FAIL on the auth-related cases. + +- [ ] **Step 3: Replace `apps/web/src/middleware.ts`** + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE_NAME, verifySession } from "./lib/auth-cookie"; + +const PUBLIC_PATHS = new Set([ + "/login", + "/logout", + "/api/health", + "/manifest.webmanifest", + "/favicon.ico", + "/robots.txt", +]); + +function isPublic(path: string): boolean { + if (PUBLIC_PATHS.has(path)) return true; + if (path.startsWith("/icon-")) return true; + if (path.startsWith("/_next/")) return true; + return false; +} + +export async function middleware(req: NextRequest): Promise { + const path = req.nextUrl.pathname; + if (isPublic(path)) return NextResponse.next(); + + const cookie = req.cookies.get(COOKIE_NAME)?.value; + const secret = process.env.AUTH_SECRET; + const ok = + !!cookie && !!secret && (await verifySession(cookie, secret)) !== null; + if (ok) return NextResponse.next(); + + if (path.startsWith("/api/")) { + return new NextResponse("Unauthorized", { status: 401 }); + } + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.searchParams.set("next", path + (req.nextUrl.search || "")); + return NextResponse.redirect(url); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image).*)"], +}; +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run middleware +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/middleware.ts apps/web/src/middleware.test.ts +git commit -m "feat(web): middleware gates non-allowlisted paths on session cookie + +Edge-runtime check via auth-cookie.verifySession. /api/* paths get a +401 (no body) when unauthenticated; pages get a 307 to /login with +the original path encoded into ?next=. Allowlist explicitly excludes +/api/events and /api/qr — both were unauthenticated in v1.1.0 and +let an unauthenticated client snoop the entire SSE event stream and +enumerate paired account QR codes." +``` + +--- + +## Task 9: User management actions + page (TDD) + +**Files:** +- Create: `apps/web/src/actions/users.test.ts` +- Create: `apps/web/src/actions/users.ts` +- Create: `apps/web/src/app/settings/users/page.tsx` +- Create: `apps/web/src/app/settings/users/user-row-client.tsx` + +- [ ] **Step 1: Write the failing test file** + +Create `apps/web/src/actions/users.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const requireAdminMock = vi.fn(); +const findUserMock = vi.fn(); +const findManyAdminsMock = vi.fn(); +const insertReturningMock = vi.fn(); +const updateMock = vi.fn(); +const deleteMock = vi.fn(); +const checkRateLimitMock = vi.fn(); +const revalidateMock = vi.fn(); + +vi.mock("@/lib/auth", async () => { + const actual = await vi.importActual("@/lib/auth"); + return { + ...actual, + requireAdmin: () => requireAdminMock(), + }; +}); +vi.mock("@/lib/db", () => ({ + db: { + query: { + operators: { + findFirst: (...a: unknown[]) => findUserMock(...a), + findMany: (...a: unknown[]) => findManyAdminsMock(...a), + }, + }, + insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }), + update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }), + delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }), + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a), +})); +vi.mock("next/cache", () => ({ revalidatePath: revalidateMock })); +vi.mock("next/headers", () => ({ + headers: async () => ({ get: () => "127.0.0.1" }), +})); + +beforeEach(() => { + requireAdminMock.mockReset(); + findUserMock.mockReset(); + findManyAdminsMock.mockReset(); + insertReturningMock.mockReset(); + updateMock.mockReset(); + deleteMock.mockReset(); + checkRateLimitMock.mockReset(); + revalidateMock.mockReset(); + checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 }); +}); + +const ADMIN = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + role: "admin" as const, +}; +const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" }; +const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const }; + +import { + createUserAction, + setUserRoleAction, + resetUserPasswordAction, + deleteUserAction, +} from "./users"; + +describe("createUserAction", () => { + it("admin can create a user with role 'user'", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + insertReturningMock.mockResolvedValue([{ id: USER.id }]); + const r = await createUserAction({ + username: "bob", + password: "longenoughpw", + role: "user", + }); + expect(r).toEqual({ ok: true, userId: USER.id }); + }); + + it("rejects username/password under length limits", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + const r = await createUserAction({ username: "a", password: "shortpw", role: "user" }); + expect(r.ok).toBe(false); + }); +}); + +describe("setUserRoleAction — self-demote guard", () => { + it("admin demoting themselves is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(ADMIN); + const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" }); + expect(r).toEqual({ + ok: false, + error: "You can't demote your own account.", + }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("admin demoting another admin is allowed when others remain", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins + const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); + expect(r).toEqual({ ok: true }); + }); + + it("admin demoting the last remaining admin is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin + const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/last admin/i); + }); +}); + +describe("deleteUserAction", () => { + it("admin deleting themselves is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(ADMIN); + const r = await deleteUserAction({ userId: ADMIN.id }); + expect(r).toEqual({ ok: false, error: "You can't delete your own account." }); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + it("admin deleting another user is allowed", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); + const r = await deleteUserAction({ userId: USER.id }); + expect(r).toEqual({ ok: true }); + }); + + it("admin deleting the last admin is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total + const r = await deleteUserAction({ userId: OTHER_ADMIN.id }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/last admin/i); + }); +}); + +describe("resetUserPasswordAction", () => { + it("admin can reset another user's password", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "longenoughpw" }); + expect(r).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalled(); + }); + + it("rejects too-short passwords", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "short" }); + expect(r.ok).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run — verify fails** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run actions/users +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `apps/web/src/actions/users.ts`** + +```ts +"use server"; + +import { eq } from "drizzle-orm"; +import bcrypt from "bcryptjs"; +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { operators } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { requireAdmin } from "@/lib/auth"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MIN_PASSWORD_LEN = 10; +const MAX_FIELD_LEN = 256; + +async function rateLimit(key: string): Promise<{ limited: boolean }> { + const h = await headers(); + const ip = + h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 }); +} + +export type CreateUserResult = + | { ok: true; userId: string } + | { ok: false; error: string }; + +export async function createUserAction(input: { + username: string; + password: string; + role: "admin" | "user"; +}): Promise { + await requireAdmin(); + const rl = await rateLimit("create-user"); + if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." }; + const u = input.username.trim(); + if (u.length < 3 || u.length > MAX_FIELD_LEN) { + return { ok: false, error: "Username must be 3..256 chars." }; + } + if (!input.password || input.password.length < MIN_PASSWORD_LEN || input.password.length > MAX_FIELD_LEN) { + return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` }; + } + if (input.role !== "admin" && input.role !== "user") { + return { ok: false, error: "Role must be admin or user." }; + } + const hash = await bcrypt.hash(input.password, 12); + const [row] = await db + .insert(operators) + .values({ + username: u, + passwordHash: hash, + displayName: u, + role: input.role, + defaultTimezone: "Asia/Kuala_Lumpur", + telegramUserId: Date.now(), + }) + .returning({ id: operators.id }); + revalidatePath("/settings/users"); + return { ok: true, userId: row!.id }; +} + +export type SetRoleResult = { ok: true } | { ok: false; error: string }; + +export async function setUserRoleAction(input: { + userId: string; + role: "admin" | "user"; +}): Promise { + const me = await requireAdmin(); + if (input.userId === me.id && input.role !== "admin") { + return { ok: false, error: "You can't demote your own account." }; + } + const target = await db.query.operators.findFirst({ + where: (o, { eq: dEq }) => dEq(o.id, input.userId), + }); + if (!target) return { ok: false, error: "User not found." }; + + // If we're demoting an admin, make sure at least one admin remains. + if (target.role === "admin" && input.role !== "admin") { + const admins = await db.query.operators.findMany({ + where: (o, { eq: dEq }) => dEq(o.role, "admin"), + }); + if (admins.length <= 1) { + return { ok: false, error: "Can't demote the last admin. Promote another user first." }; + } + } + + await db + .update(operators) + .set({ role: input.role }) + .where(eq(operators.id, input.userId)); + revalidatePath("/settings/users"); + return { ok: true }; +} + +export type DeleteUserResult = { ok: true } | { ok: false; error: string }; + +export async function deleteUserAction(input: { + userId: string; +}): Promise { + const me = await requireAdmin(); + if (input.userId === me.id) { + return { ok: false, error: "You can't delete your own account." }; + } + const target = await db.query.operators.findFirst({ + where: (o, { eq: dEq }) => dEq(o.id, input.userId), + }); + if (!target) return { ok: false, error: "User not found." }; + if (target.role === "admin") { + const admins = await db.query.operators.findMany({ + where: (o, { eq: dEq }) => dEq(o.role, "admin"), + }); + if (admins.length <= 1) { + return { ok: false, error: "Can't delete the last admin. Promote another user first." }; + } + } + await db.delete(operators).where(eq(operators.id, input.userId)); + revalidatePath("/settings/users"); + return { ok: true }; +} + +export type ResetPasswordResult = { ok: true } | { ok: false; error: string }; + +export async function resetUserPasswordAction(input: { + userId: string; + newPassword: string; +}): Promise { + await requireAdmin(); + const rl = await rateLimit("reset-password"); + if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." }; + if ( + !input.newPassword || + input.newPassword.length < MIN_PASSWORD_LEN || + input.newPassword.length > MAX_FIELD_LEN + ) { + return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` }; + } + const target = await db.query.operators.findFirst({ + where: (o, { eq: dEq }) => dEq(o.id, input.userId), + }); + if (!target) return { ok: false, error: "User not found." }; + const hash = await bcrypt.hash(input.newPassword, 12); + await db + .update(operators) + .set({ passwordHash: hash }) + .where(eq(operators.id, input.userId)); + revalidatePath("/settings/users"); + return { ok: true }; +} +``` + +- [ ] **Step 4: Implement the page + per-row client** + +`apps/web/src/app/settings/users/user-row-client.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { Loader2Icon, Trash2Icon, KeyIcon, ArrowUpDownIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + setUserRoleAction, + resetUserPasswordAction, + deleteUserAction, +} from "@/actions/users"; + +interface UserRowClientProps { + user: { id: string; username: string; role: "admin" | "user" }; + isSelf: boolean; +} + +export function UserRowClient({ user, isSelf }: UserRowClientProps) { + const [pending, start] = useTransition(); + const [error, setError] = useState(null); + const [resetVisible, setResetVisible] = useState(false); + const [resetPw, setResetPw] = useState(""); + + function run(promise: Promise) { + start(async () => { + setError(null); + const r = await promise; + if (!r.ok) setError(r.error ?? "Failed"); + }); + } + + return ( +
+
+
+

{user.username}

+

{user.role}{isSelf && " · you"}

+
+
+ + + +
+
+ {resetVisible && ( +
+ setResetPw(e.target.value)} + maxLength={256} + /> + +
+ )} + {error &&

{error}

} +
+ ); +} +``` + +`apps/web/src/app/settings/users/page.tsx`: + +```tsx +import { requireAdmin } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { PageShell } from "@/components/page-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { UserRowClient } from "./user-row-client"; + +export default async function UsersPage() { + const me = await requireAdmin(); + const rows = await db.query.operators.findMany({ + orderBy: (o, { asc }) => [asc(o.username)], + }); + + return ( + + + + {rows.map((u) => ( + + ))} + + + + ); +} +``` + +- [ ] **Step 5: Run all users tests** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run actions/users +``` + +Expected: PASS, 9 tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/actions/users.ts apps/web/src/actions/users.test.ts apps/web/src/app/settings/users +git commit -m "feat(web): user-management surface (admin only) + +createUserAction, setUserRoleAction, resetUserPasswordAction, +deleteUserAction — all gated by requireAdmin(). Self-demote and +last-admin guards prevent the operator from accidentally locking +themselves out. /settings/users page lists every operator with +inline Demote/Promote, Reset password, and Delete buttons. 9 unit +tests." +``` + +--- + +## Task 10: robots, layout meta, allowedOrigins, container hardening, rate limits, env example + +**Files:** +- Create: `apps/web/src/app/robots.ts` +- Modify: `apps/web/src/app/layout.tsx` (export `metadata.robots`) +- Modify: `apps/web/next.config.ts` (allowedOrigins) +- Modify: `docker/bot.Dockerfile` +- Modify: `docker/web.Dockerfile` +- Modify: `apps/web/src/actions/groups.ts` (rate limit `sendTestAction`) +- Modify: `apps/web/src/actions/reminders.ts` (rate limits on resume/cancel) +- Create: `apps/web/.env.example` +- Create: `packages/db/src/scripts/set-password.ts` +- Create: `packages/db/src/scripts/create-user.ts` +- Create: `scripts/set-password.sh` +- Create: `scripts/create-user.sh` + +- [ ] **Step 1: Create `apps/web/src/app/robots.ts`** + +```ts +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { rules: [{ userAgent: "*", disallow: "/" }] }; +} +``` + +- [ ] **Step 2: Edit `apps/web/src/app/layout.tsx` — add `metadata.robots`** + +Find the existing `export const metadata: Metadata = { ... }` block and add: + +```ts +robots: { index: false, follow: false }, +``` + +- [ ] **Step 3: Edit `apps/web/next.config.ts` — add `serverActions.allowedOrigins`** + +Inside the `experimental: { serverActions: { ... } }` block, add a sibling property: + +```ts +allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"], +``` + +The block becomes: + +```ts +experimental: { + typedRoutes: true, + serverActions: { + allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"], + bodySizeLimit: "100mb", + }, +}, +``` + +- [ ] **Step 4: Add rate limit to `sendTestAction` in `apps/web/src/actions/groups.ts`** + +Find the `sendTestAction` function. After the existing `await rateLimit("send-test");` call (or, if there isn't one keyed per group, add the per-group limiter after the existing IP one): + +```ts +const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 }); +if (groupRl.limited) { + return { ok: false, error: "Too many tests for this group. Try again later." }; +} +``` + +Add the `checkRateLimit` import at the top if missing: + +```ts +import { checkRateLimit } from "@/lib/rate-limit"; +``` + +- [ ] **Step 5: Add rate limits to `resumeReminderRunAction` and `cancelReminderRunAction`** + +In `apps/web/src/actions/reminders.ts`, at the top of each action body add: + +```ts +const ip = + (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; +const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 }); +if (rl.limited) { + return { ok: false, error: "Too many requests. Try again later." }; +} +``` + +(`headers` and `checkRateLimit` should already be imported in this file; if not, add `import { headers } from "next/headers";` and `import { checkRateLimit } from "@/lib/rate-limit";`.) + +- [ ] **Step 6: Harden `docker/bot.Dockerfile`** + +After the `RUN npm install -g pnpm@9.12.0` line (or wherever the base layer ends), insert before `WORKDIR /app`: + +```dockerfile +RUN addgroup -g 1000 app && \ + adduser -D -u 1000 -G app -s /sbin/nologin app && \ + mkdir -p /data/sessions /data/media /app && \ + chown -R app:app /app /data && \ + chmod 700 /data/sessions +USER app +``` + +(The exact final placement is at the end of the runtime stage so dev-mode `tsx` can still write its `.cache` files into a writable HOME — set `ENV HOME=/tmp` if needed.) + +- [ ] **Step 7: Harden `docker/web.Dockerfile`** + +Same pattern at the end of the `runtime` stage: + +```dockerfile +RUN addgroup -g 1000 app && \ + adduser -D -u 1000 -G app -s /sbin/nologin app && \ + chown -R app:app /app +USER app +``` + +- [ ] **Step 8: Create `apps/web/.env.example`** + +```text +# Required +DATABASE_URL=postgres://user:pass@host:5432/dbname + +# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh. +AUTH_SECRET=replace-me + +# Bump to invalidate all live sessions instantly. Leave at 1 normally. +OPERATOR_TOKEN_VERSION=1 + +# File-storage paths inside the bot container +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media + +# Bot fan-out tuning (see apps/bot/src/env.ts) +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=info +BOT_FIRE_CONCURRENCY=8 +BOT_GROUP_CONCURRENCY=3 +BOT_MAX_SEND_PER_MINUTE=40 + +# Web +WEB_PORT=9000 +``` + +- [ ] **Step 9: Create `packages/db/src/scripts/set-password.ts`** + +```ts +import bcrypt from "bcryptjs"; +import { sql } from "drizzle-orm"; +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { createClient } from "../index.js"; + +async function main() { + const username = process.argv[2]; + if (!username) { + console.error("Usage: set-password "); + process.exit(2); + } + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + // Silenced password prompt. + const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + const rl = createInterface({ input: process.stdin, output: muted, terminal: true }); + process.stdout.write("Password: "); + const password = await rl.question(""); + rl.close(); + process.stdout.write("\n"); + if (password.length < 10) { + console.error("Password must be at least 10 characters."); + process.exit(2); + } + const hash = await bcrypt.hash(password, 12); + const { db, pool } = createClient(url); + const result = await db.execute( + sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`, + ); + await pool.end(); + if (result.rows.length === 0) { + console.error(`No user with username ${username}`); + process.exit(1); + } + console.log("Password updated."); + process.exit(0); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 10: Create `packages/db/src/scripts/create-user.ts`** + +```ts +import bcrypt from "bcryptjs"; +import { sql } from "drizzle-orm"; +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { createClient } from "../index.js"; + +async function main() { + const username = process.argv[2]; + const role = process.argv[3]; + if (!username || (role !== "admin" && role !== "user")) { + console.error("Usage: create-user "); + process.exit(2); + } + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + const rl = createInterface({ input: process.stdin, output: muted, terminal: true }); + process.stdout.write("Password: "); + const password = await rl.question(""); + rl.close(); + process.stdout.write("\n"); + if (password.length < 10) { + console.error("Password must be at least 10 characters."); + process.exit(2); + } + const hash = await bcrypt.hash(password, 12); + const { db, pool } = createClient(url); + await db.execute( + sql`INSERT INTO operators (username, password_hash, display_name, role, telegram_user_id, default_timezone) + VALUES (${username}, ${hash}, ${username}, ${role}, ${Date.now()}, 'Asia/Kuala_Lumpur')`, + ); + await pool.end(); + console.log(`Created ${role} ${username}.`); + process.exit(0); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 11: Create the shell wrappers** + +`scripts/set-password.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/set-password.ts "$@" +``` + +`scripts/create-user.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@" +``` + +```bash +chmod +x scripts/set-password.sh scripts/create-user.sh +``` + +- [ ] **Step 12: Bootstrap the first admin password** + +```bash +./scripts/set-password.sh admin +``` + +(prompt for a password ≥10 chars). Verify success message. + +- [ ] **Step 13: Restart web + bot, smoke-test the gate** + +```bash +NO_SUDO=1 docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml restart web bot +``` + +```bash +curl -i -s http://localhost:9000/dashboard | head -5 +``` + +Expected: `HTTP/1.1 307 Temporary Redirect` with `Location: /login?next=%2Fdashboard`. + +```bash +curl -i -s http://localhost:9000/api/events | head -3 +``` + +Expected: `HTTP/1.1 401`. + +```bash +curl -i -s http://localhost:9000/login | head -3 +``` + +Expected: `HTTP/1.1 200`. + +- [ ] **Step 14: Run full suites** + +```bash +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run +``` + +Expected: all green. Web should now sit around 471 + ~38 new tests = ~509 passing. + +- [ ] **Step 15: Commit the bundled hardening** + +```bash +git add apps/web/src/app/robots.ts apps/web/src/app/layout.tsx apps/web/next.config.ts \ + apps/web/src/actions/groups.ts apps/web/src/actions/reminders.ts \ + apps/web/.env.example \ + packages/db/src/scripts/ scripts/set-password.sh scripts/create-user.sh \ + docker/bot.Dockerfile docker/web.Dockerfile +git commit -m "feat: production hardening — robots, allowedOrigins, container non-root, rate limits, CLI bootstrap + +robots.ts + metadata.robots blocks indexing. +serverActions.allowedOrigins gates cross-origin Server Action posts. +Bot + web Dockerfiles add a non-root 'app' user (uid 1000) with +chmod 700 on /data/sessions. +sendTestAction grows a per-group rate limit (3/60s). +resumeReminderRunAction + cancelReminderRunAction get a per-IP +rate limit (30/10s). +.env.example documents every required key. +packages/db/src/scripts/{set-password,create-user}.ts + thin shell +wrappers in scripts/ — first admin sets their password via +./scripts/set-password.sh admin before signing in." +``` + +--- + +## Acceptance check (manual) + +After all 10 tasks land: + +- [ ] **Smoke 1.** `curl -i http://localhost:9000/dashboard` → 307 to `/login?next=%2Fdashboard`. `curl -i http://localhost:9000/api/events` → 401. `curl -i http://localhost:9000/login` → 200. + +- [ ] **Smoke 2.** Open `/login` in a browser. Submit empty form → "required" error. Submit wrong password → generic "Invalid username or password" with no leak about which field was wrong. + +- [ ] **Smoke 3.** Submit correct credentials → land on `/`. Reopen browser; cookie persists; refresh → still authenticated. + +- [ ] **Smoke 4.** Bump `OPERATOR_TOKEN_VERSION` from `1` to `2` in `.env.development` and restart web. Refresh the page → redirected to `/login` (cookie's `v` no longer matches). Sign in again with the same password → land on `/` (new cookie issued at v=2). + +- [ ] **Smoke 5.** As `admin`, visit `/settings/users`. Add a new user `bob` with role `user`. Sign out, sign in as `bob`, visit `/settings/users` → 401-equivalent (the page calls `requireAdmin` and throws). + +- [ ] **Smoke 6.** As `admin`, try to demote yourself → "You can't demote your own account." Try to delete yourself → "You can't delete your own account." Promote `bob` to admin, then sign out as the original admin and have only one admin left; try to demote that admin from another admin's session → "Can't demote the last admin." + +- [ ] **Smoke 7.** Visit `/robots.txt` directly → returns `User-agent: *\nDisallow: /`. View page source on `/login` → `` present. + +- [ ] **Smoke 8.** Final test sweep: + + ```bash + NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run + NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run + NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run + ``` + + Expected: all green. ~509 web + 60 bot + 39 shared = ~608 total.