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) <noreply@anthropic.com>
2318 lines
74 KiB
Markdown
2318 lines
74 KiB
Markdown
# 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_<name>.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_<random_name>.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<string, unknown>;
|
|
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<CryptoKey> {
|
|
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<string> {
|
|
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<SessionPayload | null> {
|
|
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,<script>x</script>")).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<string> {
|
|
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<AuthUser | null> {
|
|
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<AuthUser> {
|
|
const u = await getCurrentUser();
|
|
if (!u) throw new UnauthenticatedError();
|
|
return u;
|
|
}
|
|
|
|
export async function requireAdmin(): Promise<AuthUser> {
|
|
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<string, string>): 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<string> {
|
|
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<LoginResult> {
|
|
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<void> {
|
|
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<string | null>(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 (
|
|
<form action={handle} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="username">Username</Label>
|
|
<Input
|
|
id="username"
|
|
name="username"
|
|
type="text"
|
|
autoComplete="username"
|
|
autoFocus
|
|
required
|
|
maxLength={256}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="password">Password</Label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
maxLength={256}
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<div className="text-xs text-destructive">{error}</div>
|
|
)}
|
|
<Button type="submit" disabled={pending} className="w-full gap-2">
|
|
{pending ? (
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
) : (
|
|
<LockIcon className="size-4" />
|
|
)}
|
|
Sign in
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
First time? Run <code>./scripts/set-password.sh <username></code>{" "}
|
|
in your tools container.
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<div className="min-h-dvh flex items-center justify-center px-4 py-8">
|
|
<Card className="w-full max-w-sm">
|
|
<CardHeader>
|
|
<CardTitle>Sign in</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LoginFormClient next={next} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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<NextRequest> {
|
|
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<string> {
|
|
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/<id> 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<string>([
|
|
"/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<NextResponse> {
|
|
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<typeof import("@/lib/auth")>("@/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<CreateUserResult> {
|
|
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<SetRoleResult> {
|
|
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<DeleteUserResult> {
|
|
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<ResetPasswordResult> {
|
|
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<string | null>(null);
|
|
const [resetVisible, setResetVisible] = useState(false);
|
|
const [resetPw, setResetPw] = useState("");
|
|
|
|
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
|
|
start(async () => {
|
|
setError(null);
|
|
const r = await promise;
|
|
if (!r.ok) setError(r.error ?? "Failed");
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{user.username}</p>
|
|
<p className="text-xs text-muted-foreground">{user.role}{isSelf && " · you"}</p>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={pending || isSelf}
|
|
onClick={() =>
|
|
run(
|
|
setUserRoleAction({
|
|
userId: user.id,
|
|
role: user.role === "admin" ? "user" : "admin",
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
<ArrowUpDownIcon className="size-3.5" />
|
|
{user.role === "admin" ? "Demote" : "Promote"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={pending}
|
|
onClick={() => setResetVisible((v) => !v)}
|
|
>
|
|
<KeyIcon className="size-3.5" />
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
disabled={pending || isSelf}
|
|
onClick={() => run(deleteUserAction({ userId: user.id }))}
|
|
>
|
|
{pending ? <Loader2Icon className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{resetVisible && (
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="password"
|
|
placeholder="New password (≥10 chars)"
|
|
value={resetPw}
|
|
onChange={(e) => setResetPw(e.target.value)}
|
|
maxLength={256}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={pending}
|
|
onClick={() => {
|
|
run(resetUserPasswordAction({ userId: user.id, newPassword: resetPw }));
|
|
setResetPw("");
|
|
setResetVisible(false);
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
`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 (
|
|
<PageShell title="Users">
|
|
<Card>
|
|
<CardContent className="space-y-3 py-4">
|
|
{rows.map((u) => (
|
|
<UserRowClient
|
|
key={u.id}
|
|
user={{
|
|
id: u.id,
|
|
username: u.username,
|
|
role: (u.role === "admin" ? "admin" : "user"),
|
|
}}
|
|
isSelf={u.id === me.id}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</PageShell>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 <username>");
|
|
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 <username> <admin|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` → `<meta name="robots" content="noindex, nofollow">` 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.
|