diff --git a/docs/superpowers/plans/2026-07-01-workers-completion.md b/docs/superpowers/plans/2026-07-01-workers-completion.md new file mode 100644 index 0000000..3194475 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-workers-completion.md @@ -0,0 +1,723 @@ +# Workers Completion 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:** Finish the post-PR Workers runtime work by making the Web Console Fetch path production-ready, adding Worker-safe sessions, validating Cloudflare D1 deployment, and updating docs. + +**Architecture:** Keep one business core and split only at adapters. Node keeps `node:http` hosting, Workers use Fetch `Request`/`Response`, D1, webhook, and scheduled events through platform adapters. + +**Tech Stack:** Node 24, TypeScript 6, grammY, pino, zod, node:sqlite, Cloudflare Workers, Cloudflare D1, wrangler. + +## Global Constraints + +- Run TDD for each implementation task: RED first, then GREEN. +- Run `npm run verify` before any completion claim. +- Keep `SqlValue = string | number | bigint | null | Uint8Array`. +- Keep domain code independent from Node APIs and Cloudflare APIs. +- Keep Node runtime behavior working while adding Workers behavior. +- Do not log or document token, password, setup token, webhook secret, or API key values. +- Keep Web Console visual style aligned with the existing Vercel/Next.js minimalist black, white, gray, and single blue accent design. + +--- + +## File Structure + +- Modify `src/runtime/web-console.ts`: expand `handleWebConsoleRequest()` from initial Fetch slice to full Web Console route handler, including auth, dashboard pages, config forms, operations pages, JSON endpoints, redirects, and shared rendering. +- Modify `src/runtime/main.ts`: turn Node `startWebConsole` into a thin Node HTTP adapter that delegates to `handleWebConsoleRequest()`. +- Create `src/runtime/web-console-session.ts`: Worker-safe signed cookie session helpers with HMAC, expiration, and constant-time verification. +- Modify `src/runtime/worker.ts`: route Web Console requests through Fetch handler and pass Worker session options. +- Modify `src/runtime/config.ts`: add optional session secret config parsing if needed by Worker Web Console. +- Modify `test/core.test.ts`: add tests for Fetch routes, signed cookie behavior, Node adapter delegation, and Worker routing. +- Modify `wrangler.toml`: document required Worker variables and keep D1 binding stable. +- Modify Wiki files in `/workspace/InBoxBridge.wiki`: update deployment and Web Console docs after implementation. + +--- + +### Task 1: Expand Web Console Fetch Routing + +**Files:** +- Modify: `src/runtime/web-console.ts` +- Test: `test/core.test.ts` + +**Interfaces:** +- Consumes: `handleWebConsoleRequest(request, options, sessions?)` +- Produces: Fetch route coverage for `/`, `/config`, `/config/:section`, `/operations`, `/operations/:section`, `/metrics`, `/login`, `/logout`, and `/healthz` + +- [ ] **Step 1: Write failing tests for authenticated Fetch pages** + +Add tests in `test/core.test.ts` under the `web console` suite: + +```ts +test("serves authenticated Web Console pages through Fetch requests", async () => { + const sessions = new Map([["session-id", "admin"]]); + const options = createWebConsoleTestOptions(); + + const overview = await handleWebConsoleRequest( + new Request("http://localhost/", { + headers: { cookie: "inboxbridge_session=session-id" }, + }), + options, + sessions, + ); + assert.equal(overview.status, 200); + assert.match(await overview.text(), /Console Overview/); + + const config = await handleWebConsoleRequest( + new Request("http://localhost/config", { + headers: { cookie: "inboxbridge_session=session-id" }, + }), + options, + sessions, + ); + assert.equal(config.status, 200); + assert.match(await config.text(), /Configuration/); + + const operations = await handleWebConsoleRequest( + new Request("http://localhost/operations", { + headers: { cookie: "inboxbridge_session=session-id" }, + }), + options, + sessions, + ); + assert.equal(operations.status, 200); + assert.match(await operations.text(), /Operations/); +}); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npm test -- --test-name-pattern="serves authenticated Web Console pages through Fetch requests"` + +Expected: FAIL because Fetch handler only supports the first Web Console slice. + +- [ ] **Step 3: Implement route dispatch in `handleWebConsoleRequest()`** + +Use the existing render helpers in `src/runtime/web-console.ts`. Add a route table near the current Fetch handler: + +```ts +if (request.method === "GET" && pathname === "/") { + await renderOverview(sink, options, sessionKind); + return sink.toResponse(); +} + +if (request.method === "GET" && pathname === "/config") { + await renderConfigDashboard(sink, options, sessionKind); + return sink.toResponse(); +} + +if (request.method === "GET" && pathname.startsWith("/config/")) { + await renderConfigSection(sink, options, sessionKind, pathname); + return sink.toResponse(); +} + +if (request.method === "GET" && pathname === "/operations") { + await renderOperationsOverview(sink, options, sessionKind); + return sink.toResponse(); +} + +if (request.method === "GET" && pathname.startsWith("/operations/")) { + await renderOperationsSection(sink, options, sessionKind, pathname, url.searchParams); + return sink.toResponse(); +} +``` + +If the current render functions are nested inside Node-only code, move them to top-level functions in the same file without changing HTML output. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `npm test -- --test-name-pattern="serves authenticated Web Console pages through Fetch requests"` + +Expected: PASS. + +- [ ] **Step 5: Run full verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, 63+ tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 6: Commit** + +```bash +git add src/runtime/web-console.ts test/core.test.ts +git commit -m "feat(console): route dashboard pages through Fetch" +``` + +--- + +### Task 2: Add Fetch POST Login and Logout + +**Files:** +- Modify: `src/runtime/web-console.ts` +- Test: `test/core.test.ts` + +**Interfaces:** +- Consumes: `WebConsoleSessionStore` +- Produces: Fetch support for `POST /login` and `POST /logout` + +- [ ] **Step 1: Write failing tests for login and logout** + +```ts +test("authenticates Web Console sessions through Fetch login", async () => { + const sessions = new Map(); + const options = createWebConsoleTestOptions({ password: "secret-password" }); + + const response = await handleWebConsoleRequest( + new Request("http://localhost/login", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password: "secret-password" }), + }), + options, + sessions, + ); + + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), "/"); + assert.match(response.headers.get("set-cookie") ?? "", /inboxbridge_session=/); + assert.equal(sessions.size, 1); +}); + +test("logs out Web Console sessions through Fetch logout", async () => { + const sessions = new Map([["session-id", "admin"]]); + const options = createWebConsoleTestOptions(); + + const response = await handleWebConsoleRequest( + new Request("http://localhost/logout", { + method: "POST", + headers: { cookie: "inboxbridge_session=session-id" }, + }), + options, + sessions, + ); + + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), "/login"); + assert.equal(sessions.has("session-id"), false); +}); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npm test -- --test-name-pattern="Fetch login|Fetch logout"` + +Expected: FAIL because POST Fetch auth is incomplete. + +- [ ] **Step 3: Implement POST handling** + +In `handleWebConsoleRequest()`: + +```ts +if (request.method === "POST" && pathname === "/login") { + const form = await request.formData(); + const password = String(form.get("password") ?? ""); + const sessionKind = await authenticateWebConsolePassword(options, password); + if (!sessionKind) { + await renderLogin(sink, options, "Invalid password"); + return sink.toResponse(); + } + + const sessionId = createSessionId(); + sessions.set(sessionId, sessionKind); + sink.redirect("/"); + sink.setCookie(createSessionCookie(sessionId)); + return sink.toResponse(); +} + +if (request.method === "POST" && pathname === "/logout") { + const sessionId = readSessionCookie(request.headers.get("cookie") ?? ""); + if (sessionId) { + sessions.delete(sessionId); + } + sink.redirect("/login"); + sink.setCookie(expireSessionCookie()); + return sink.toResponse(); +} +``` + +Keep the existing unauthenticated body-size guard. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `npm test -- --test-name-pattern="Fetch login|Fetch logout"` + +Expected: PASS. + +- [ ] **Step 5: Run full verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, all tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 6: Commit** + +```bash +git add src/runtime/web-console.ts test/core.test.ts +git commit -m "feat(console): handle Fetch login sessions" +``` + +--- + +### Task 3: Add Worker-Safe Signed Cookie Sessions + +**Files:** +- Create: `src/runtime/web-console-session.ts` +- Modify: `src/runtime/web-console.ts` +- Modify: `src/runtime/worker.ts` +- Test: `test/core.test.ts` + +**Interfaces:** +- Produces: `createSignedSessionCookie(input)`, `verifySignedSessionCookie(input)`, `expireSessionCookie()` +- Consumes: Web Crypto `crypto.subtle` available in Workers and Node 24 test runtime + +- [ ] **Step 1: Write failing tests for signed cookie verification** + +```ts +test("signs and verifies Web Console cookies without server memory", async () => { + const cookie = await createSignedSessionCookie({ + secret: "session-secret-value", + kind: "admin", + now: new Date("2026-07-01T00:00:00.000Z"), + maxAgeSeconds: 3600, + }); + + const verified = await verifySignedSessionCookie({ + secret: "session-secret-value", + cookieHeader: cookie, + now: new Date("2026-07-01T00:10:00.000Z"), + }); + + assert.equal(verified, "admin"); +}); + +test("rejects expired Web Console signed cookies", async () => { + const cookie = await createSignedSessionCookie({ + secret: "session-secret-value", + kind: "admin", + now: new Date("2026-07-01T00:00:00.000Z"), + maxAgeSeconds: 60, + }); + + const verified = await verifySignedSessionCookie({ + secret: "session-secret-value", + cookieHeader: cookie, + now: new Date("2026-07-01T00:02:00.000Z"), + }); + + assert.equal(verified, null); +}); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npm test -- --test-name-pattern="signed cookies|expired Web Console"` + +Expected: FAIL because `src/runtime/web-console-session.ts` does not exist. + +- [ ] **Step 3: Implement signed cookie helpers** + +Create `src/runtime/web-console-session.ts`: + +```ts +export type SessionKind = "setup" | "admin"; + +export type CreateSignedSessionCookieInput = { + secret: string; + kind: SessionKind; + now: Date; + maxAgeSeconds: number; +}; + +export type VerifySignedSessionCookieInput = { + secret: string; + cookieHeader: string; + now: Date; +}; + +const COOKIE_NAME = "inboxbridge_session"; + +export async function createSignedSessionCookie(input: CreateSignedSessionCookieInput): Promise { + const expiresAt = Math.floor(input.now.getTime() / 1000) + input.maxAgeSeconds; + const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ kind: input.kind, exp: expiresAt }))); + const signature = await sign(input.secret, payload); + return `${COOKIE_NAME}=${payload}.${signature}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${input.maxAgeSeconds}`; +} + +export async function verifySignedSessionCookie(input: VerifySignedSessionCookieInput): Promise { + const raw = readCookie(input.cookieHeader, COOKIE_NAME); + if (!raw) return null; + const [payload, signature] = raw.split("."); + if (!payload || !signature) return null; + const expected = await sign(input.secret, payload); + if (!constantTimeEqual(signature, expected)) return null; + + const decoded = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as { kind?: string; exp?: number }; + if (decoded.kind !== "setup" && decoded.kind !== "admin") return null; + if (typeof decoded.exp !== "number") return null; + if (decoded.exp <= Math.floor(input.now.getTime() / 1000)) return null; + return decoded.kind; +} + +export function expireSessionCookie(): string { + return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} +``` + +Add local helpers `sign`, `base64UrlEncode`, `base64UrlDecode`, `readCookie`, and `constantTimeEqual` in the same file using Web Crypto and no Node imports. + +- [ ] **Step 4: Wire Worker session mode into Fetch handler** + +Add an optional session secret to the Fetch handler options: + +```ts +export type WebConsoleFetchOptions = WebConsoleOptions & { + sessionSecret?: string; + now?: () => Date; +}; +``` + +When `sessionSecret` is present, authenticate with signed cookies instead of the in-memory map. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `npm test -- --test-name-pattern="signed cookies|expired Web Console"` + +Expected: PASS. + +- [ ] **Step 6: Run full verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, all tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 7: Commit** + +```bash +git add src/runtime/web-console-session.ts src/runtime/web-console.ts src/runtime/worker.ts test/core.test.ts +git commit -m "feat(console): add signed cookie sessions" +``` + +--- + +### Task 4: Delegate Node Web Console to Fetch Handler + +**Files:** +- Modify: `src/runtime/web-console.ts` +- Modify: `src/runtime/main.ts` +- Test: `test/core.test.ts` + +**Interfaces:** +- Consumes: `handleWebConsoleRequest()` +- Produces: Node HTTP adapter that converts `IncomingMessage` to Fetch `Request` and Fetch `Response` to `ServerResponse` + +- [ ] **Step 1: Write failing test for Node adapter delegation** + +```ts +test("Node web console delegates health checks through Fetch handler", async () => { + const calls: string[] = []; + const server = await startWebConsole({ + ...createWebConsoleTestOptions(), + handleRequest: async (request) => { + calls.push(new URL(request.url).pathname); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/healthz`); + assert.equal(response.status, 200); + assert.deepEqual(calls, ["/healthz"]); + await server.close(); +}); +``` + +- [ ] **Step 2: Run test and verify RED** + +Run: `npm test -- --test-name-pattern="Node web console delegates"` + +Expected: FAIL because `startWebConsole` has no delegation injection. + +- [ ] **Step 3: Implement adapter conversion helpers** + +In `src/runtime/web-console.ts`, add: + +```ts +async function incomingMessageToRequest(req: IncomingMessage, baseUrl: string): Promise { + const url = new URL(req.url ?? "/", baseUrl); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) headers.set(key, value.join(", ")); + else if (typeof value === "string") headers.set(key, value); + } + + const method = req.method ?? "GET"; + const body = method === "GET" || method === "HEAD" ? undefined : await readNodeRequestBody(req); + return new Request(url, { method, headers, body }); +} + +async function writeFetchResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => res.setHeader(key, value)); + const body = response.body ? Buffer.from(await response.arrayBuffer()) : Buffer.alloc(0); + res.end(body); +} +``` + +Make `startWebConsole` call `handleWebConsoleRequest()` for all routes. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `npm test -- --test-name-pattern="Node web console delegates"` + +Expected: PASS. + +- [ ] **Step 5: Run full verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, all tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 6: Commit** + +```bash +git add src/runtime/web-console.ts src/runtime/main.ts test/core.test.ts +git commit -m "refactor(console): use Fetch handler from Node adapter" +``` + +--- + +### Task 5: Route Workers Web Console Requests + +**Files:** +- Modify: `src/runtime/worker.ts` +- Modify: `src/runtime/config.ts` +- Test: `test/core.test.ts` + +**Interfaces:** +- Consumes: `handleWebConsoleRequest()` and signed cookie sessions +- Produces: Worker routes for `/`, `/login`, `/logout`, `/config`, `/operations`, `/metrics`, `/healthz`, and `/telegram/webhook` + +- [ ] **Step 1: Write failing Worker routing tests** + +```ts +test("Worker runtime serves Web Console login page", async () => { + const env = createWorkerTestEnv({ WEB_CONSOLE_SESSION_SECRET: "session-secret-value" }); + const response = await handleWorkerFetch(new Request("http://worker.example/login"), env); + + assert.equal(response.status, 200); + assert.match(await response.text(), /InboxBridge/); +}); + +test("Worker runtime keeps Telegram webhook route separate from Web Console", async () => { + const env = createWorkerTestEnv({ WEB_CONSOLE_SESSION_SECRET: "session-secret-value" }); + const response = await handleWorkerFetch(new Request("http://worker.example/telegram/webhook"), env, { + telegramWebhookHandler: async () => new Response("telegram", { status: 202 }), + }); + + assert.equal(response.status, 202); + assert.equal(await response.text(), "telegram"); +}); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npm test -- --test-name-pattern="Worker runtime serves Web Console|keeps Telegram webhook"` + +Expected: FAIL because Worker only handles current limited routes. + +- [ ] **Step 3: Implement Worker route dispatch** + +In `src/runtime/worker.ts`, after `/telegram/webhook` handling and before fallback: + +```ts +if (isWebConsolePath(url.pathname)) { + const runtime = await createWorkerRuntime(env, options); + return handleWebConsoleRequest(request, { + ...runtime.webConsoleOptions, + sessionSecret: runtime.config.WEB_CONSOLE_SESSION_SECRET, + }); +} +``` + +Add: + +```ts +function isWebConsolePath(pathname: string): boolean { + return pathname === "/" || pathname === "/login" || pathname === "/logout" || pathname === "/healthz" || pathname === "/metrics" || pathname.startsWith("/config") || pathname.startsWith("/operations"); +} +``` + +- [ ] **Step 4: Add config parsing for session secret** + +In `src/runtime/config.ts`, add optional string config: + +```ts +WEB_CONSOLE_SESSION_SECRET: getOptionalString(sources, "WEB_CONSOLE_SESSION_SECRET"), +``` + +Require this value only in Worker Web Console route handling, returning `503` with a safe message when missing. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `npm test -- --test-name-pattern="Worker runtime serves Web Console|keeps Telegram webhook"` + +Expected: PASS. + +- [ ] **Step 6: Run full verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, all tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 7: Commit** + +```bash +git add src/runtime/worker.ts src/runtime/config.ts test/core.test.ts +git commit -m "feat(worker): serve web console over Fetch" +``` + +--- + +### Task 6: Add Cloudflare Deployment Smoke Checklist + +**Files:** +- Modify: `wrangler.toml` +- Modify: `/workspace/InBoxBridge.wiki/Deployment.md` +- Modify: `/workspace/InBoxBridge.wiki/Web-Console.md` +- Modify: `/workspace/InBoxBridge.wiki/Troubleshooting.md` + +**Interfaces:** +- Produces: documented Cloudflare deployment steps and smoke-test checklist + +- [ ] **Step 1: Update `wrangler.toml` comments through safe vars only** + +Keep placeholder D1 ID until deployment: + +```toml +name = "inboxbridge" +main = "src/runtime/worker.ts" +compatibility_date = "2026-07-01" + +[vars] +TELEGRAM_UPDATE_MODE = "webhook" + +[[d1_databases]] +binding = "DB" +database_name = "inboxbridge" +database_id = "00000000-0000-0000-0000-000000000000" + +[triggers] +crons = ["*/15 * * * *"] +``` + +- [ ] **Step 2: Document required secrets without values** + +In Wiki docs, document these commands with placeholders only: + +```bash +wrangler secret put TELEGRAM_BOT_TOKEN +wrangler secret put TELEGRAM_WEBHOOK_SECRET +wrangler secret put WEB_CONSOLE_PASSWORD +wrangler secret put WEB_CONSOLE_SESSION_SECRET +``` + +- [ ] **Step 3: Document smoke tests** + +Add this checklist: + +```markdown +## Cloudflare Workers Smoke Test + +- `GET /healthz` returns JSON and executes D1 `SELECT 1`. +- `GET /login` renders the Web Console login page. +- Login creates an HttpOnly session cookie. +- `GET /operations` renders after authentication. +- Telegram webhook path returns a handled response for valid secret headers. +- Scheduled event runs maintenance without throwing. +``` + +- [ ] **Step 4: Run docs checks** + +Run in Wiki repo: `git diff --check` + +Expected: no output. + +- [ ] **Step 5: Commit Wiki docs** + +```bash +git add Deployment.md Web-Console.md Troubleshooting.md +git commit -m "docs: add Cloudflare Workers smoke checklist" +git push origin master +``` + +--- + +### Task 7: Final Verification and PR + +**Files:** +- All files touched by Tasks 1-6 + +**Interfaces:** +- Produces: one GitHub PR against `main` + +- [ ] **Step 1: Run final verification** + +Run: `npm run verify` + +Expected: TypeScript check passes, all tests pass, audit reports 0 vulnerabilities. + +- [ ] **Step 2: Inspect Git state** + +Run: `git status --short` + +Expected: only intended files are modified or staged. + +- [ ] **Step 3: Inspect PR diff** + +Run: `git diff --stat origin/main...HEAD` + +Expected: diff includes runtime, tests, config, and docs intended for this plan. + +- [ ] **Step 4: Push branch** + +```bash +git push -u origin 260701-feat-worker-console-completion +``` + +- [ ] **Step 5: Create PR** + +```bash +gh pr create --base main --head 260701-feat-worker-console-completion --title "feat(worker): complete Fetch web console support" --body "## Summary +- route Web Console pages and auth through Fetch +- add signed cookie sessions for Workers +- delegate Node Web Console through the shared Fetch handler +- document Cloudflare Workers smoke testing + +## Validation +- npm run verify" +``` + +- [ ] **Step 6: Wait for checks** + +Run: `gh pr checks --watch` + +Expected: `Verify`, `Analyze TypeScript`, `CodeQL`, and `CodeRabbit` pass. + +--- + +## Acceptance Criteria + +- Web Console pages are served through Fetch in both Node and Workers paths. +- Workers Web Console auth uses signed cookies and does not depend on in-memory session state. +- Telegram webhook route remains isolated from Web Console routing. +- D1-backed `/healthz` still runs a database query per request. +- Node runtime still passes all existing tests. +- Wiki documents Cloudflare Workers deployment without leaking secret values. +- `npm run verify` passes before PR creation. + +## Self-Review + +- Spec coverage: The plan covers Web Console Fetch completion, Worker-safe sessions, Worker routing, Node adapter delegation, Cloudflare smoke docs, and PR validation. +- Placeholder scan: No implementation step uses TBD-style placeholders; secret values are intentionally represented as safe names only. +- Type consistency: Session kind remains `"setup" | "admin"`; Fetch handler remains `handleWebConsoleRequest(request, options, sessions?)`; Worker entry remains `handleWorkerFetch(request, env, options?)`. diff --git a/src/runtime/web-console-session.ts b/src/runtime/web-console-session.ts new file mode 100644 index 0000000..f94bdda --- /dev/null +++ b/src/runtime/web-console-session.ts @@ -0,0 +1,89 @@ +export type WebConsoleSessionKind = "password" | "setup"; + +export interface CreateSignedSessionCookieInput { + secret: string; + kind: WebConsoleSessionKind; + now: Date; + maxAgeSeconds: number; +} + +export interface VerifySignedSessionCookieInput { + secret: string; + cookieHeader: string; + now: Date; +} + +const cookieName = "inboxbridge_session"; + +export async function createSignedSessionCookie(input: CreateSignedSessionCookieInput): Promise { + const expiresAt = Math.floor(input.now.getTime() / 1000) + input.maxAgeSeconds; + const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ kind: input.kind, exp: expiresAt }))); + const signature = await sign(input.secret, payload); + return `${cookieName}=${payload}.${signature}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${input.maxAgeSeconds}`; +} + +export async function verifySignedSessionCookie(input: VerifySignedSessionCookieInput): Promise { + const raw = readCookie(input.cookieHeader, cookieName); + if (!raw) return null; + const [payload, signature] = raw.split("."); + if (!payload || !signature) return null; + const expected = await sign(input.secret, payload); + if (!constantTimeEqual(signature, expected)) return null; + + try { + const decoded = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as { kind?: unknown; exp?: unknown }; + if (decoded.kind !== "password" && decoded.kind !== "setup") return null; + if (typeof decoded.exp !== "number") return null; + if (decoded.exp <= Math.floor(input.now.getTime() / 1000)) return null; + return decoded.kind; + } catch { + return null; + } +} + +export function expireSessionCookie(): string { + return `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +async function sign(secret: string, payload: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + return base64UrlEncode(new Uint8Array(signature)); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function base64UrlDecode(value: string): Uint8Array { + const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function readCookie(cookieHeader: string, name: string): string | undefined { + return cookieHeader + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function constantTimeEqual(a: string, b: string): boolean { + let diff = a.length ^ b.length; + const length = Math.max(a.length, b.length); + for (let index = 0; index < length; index += 1) { + diff |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0); + } + return diff === 0; +} diff --git a/src/runtime/web-console.ts b/src/runtime/web-console.ts index 7f97282..263582c 100644 --- a/src/runtime/web-console.ts +++ b/src/runtime/web-console.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypt import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { URL } from "node:url"; import { editableConfigKeys, sensitiveConfigKeys } from "./config.js"; +import { createSignedSessionCookie, expireSessionCookie, verifySignedSessionCookie, type WebConsoleSessionKind } from "./web-console-session.js"; import { AppSettingsService } from "../domain/app-settings.js"; const passwordHashKey = "WEB_CONSOLE_PASSWORD_HASH"; @@ -271,6 +272,9 @@ export const auditActionOptions = [ export interface WebConsoleOptions { settings: AppSettingsService; port: number; + sessionSecret?: string; + sessionMaxAgeSeconds?: number; + now?: () => Date; getStatus: () => ConsoleStatus | Promise; onConfigSaved: () => Promise; telegramWebhook?: (req: IncomingMessage, res: ServerResponse) => Promise; @@ -284,7 +288,7 @@ export interface WebConsoleOptions { searchMessages: (opts: { query: string; page: number; pageSize: number }) => { items: MessageSearchView[]; total: number } | Promise<{ items: MessageSearchView[]; total: number }>; } -type SessionKind = "password" | "setup"; +type SessionKind = WebConsoleSessionKind; type OperationsTab = "overview" | "conversations" | "deliveries" | "audit" | "search"; export type WebConsoleSessionStore = Map; @@ -310,108 +314,7 @@ export async function startWebConsole(options: WebConsoleOptions): Promise 0) { - await options.scheduleRetry(deliveryId); - } - redirect(res, "/operations/deliveries?retryed=1"); - return; - } - - if (url.pathname === "/config" && req.method === "POST") { - const form = await readForm(req); - const values = await configValuesFromForm(options.settings, form); - const password = form.get("WEB_CONSOLE_PASSWORD")?.trim(); - if (sessionKind === "setup" && !password) { - send(res, 400, "text/plain", "首次配置必须设置控制台密码。"); - return; - } - if (password) values[passwordHashKey] = hashPassword(password); - if (password) values[setupTokenKey] = ""; - await options.settings.setMany(values); - await options.onConfigSaved(); - const group = form.get("group") ?? "security"; - redirect(res, `/config/${group}?saved=1`); - return; - } - - if (url.pathname === "/" && req.method === "GET") { - await renderOverview(res, options, url.searchParams.get("saved") === "1"); - return; - } - - if (url.pathname.startsWith("/config") && req.method === "GET") { - await renderConfigPage(res, options, url.pathname, url.searchParams); - return; - } - - if (url.pathname.startsWith("/operations") && req.method === "GET") { - await renderOperationsPage(res, options, url.pathname, url.searchParams); - return; - } - - send(res, 404, "text/plain", "页面不存在"); + await writeFetchResponse(res, await handleWebConsoleRequest(await incomingMessageToRequest(req, url), options, sessions)); } catch (error) { if (error instanceof FormBodyTooLargeError) { send(res, 413, "text/plain", "请求体过大。"); @@ -470,9 +373,85 @@ export async function handleWebConsoleRequest( return res.toResponse(); } - const sessionKind = authenticatedFetchSessionKind(request, sessions); + if (url.pathname === "/login" && request.method === "POST") { + const form = await readRequestForm(request); + const sessionKind = await loginSessionKind(options.settings, form); + if (sessionKind) { + const serverResponse = res.asServerResponse(); + if (options.sessionSecret) { + serverResponse.setHeader("set-cookie", await createSignedSessionCookie({ + secret: options.sessionSecret, + kind: sessionKind, + now: currentDate(options), + maxAgeSeconds: options.sessionMaxAgeSeconds ?? 60 * 60 * 8, + })); + } else { + const session = randomBytes(24).toString("hex"); + sessions.set(session, sessionKind); + serverResponse.setHeader("set-cookie", `${sessionCookie}=${session}; HttpOnly; SameSite=Lax; Path=/`); + } + redirect(serverResponse, "/"); + return res.toResponse(); + } + await renderLogin(res.asServerResponse(), options.settings, "登录凭据无效。"); + return res.toResponse(); + } + + const sessionKind = await authenticatedFetchSessionKind(request, options, sessions); if (!sessionKind) return redirectResponse("/login"); + if (url.pathname === "/logout" && request.method === "POST") { + const token = sessionTokenFromCookie(request.headers.get("cookie") ?? ""); + if (token) sessions.delete(token); + const serverResponse = res.asServerResponse(); + serverResponse.setHeader("set-cookie", expireSessionCookie()); + redirect(serverResponse, "/login"); + return res.toResponse(); + } + + if (url.pathname === "/metrics" && request.method === "GET") { + try { + return jsonResponse(await options.collectMetrics(), 200); + } catch { + return jsonResponse({ error: "metrics query failed" }, 500); + } + } + + if (url.pathname === "/operations/deliveries/retry" && request.method === "POST") { + const form = await readRequestForm(request); + const deliveryId = Number(form.get("delivery_id")); + if (deliveryId > 0) await options.scheduleRetry(deliveryId); + return redirectResponse("/operations/deliveries?retryed=1"); + } + + if (url.pathname === "/config" && request.method === "POST") { + const form = await readRequestForm(request); + const values = await configValuesFromForm(options.settings, form); + const password = form.get("WEB_CONSOLE_PASSWORD")?.trim(); + if (sessionKind === "setup" && !password) return textResponse("首次配置必须设置控制台密码。", 400); + if (password) values[passwordHashKey] = hashPassword(password); + if (password) values[setupTokenKey] = ""; + await options.settings.setMany(values); + await options.onConfigSaved(); + const group = form.get("group") ?? "security"; + return redirectResponse(`/config/${group}?saved=1`); + } + + if (url.pathname === "/" && request.method === "GET") { + await renderOverview(res.asServerResponse(), options, url.searchParams.get("saved") === "1"); + return res.toResponse(); + } + + if (url.pathname.startsWith("/config") && request.method === "GET") { + await renderConfigPage(res.asServerResponse(), options, url.pathname, url.searchParams); + return res.toResponse(); + } + + if (url.pathname.startsWith("/operations") && request.method === "GET") { + await renderOperationsPage(res.asServerResponse(), options, url.pathname, url.searchParams); + return res.toResponse(); + } + return textResponse("页面不存在", 404); } catch (error) { if (error instanceof FormBodyTooLargeError) return textResponse("请求体过大。", 413); @@ -480,6 +459,31 @@ export async function handleWebConsoleRequest( } } +async function incomingMessageToRequest(req: IncomingMessage, url: URL): Promise { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) headers.set(key, value.join(", ")); + else if (typeof value === "string") headers.set(key, value); + } + const method = req.method ?? "GET"; + const body = method === "GET" || method === "HEAD" ? undefined : await readNodeRequestBody(req); + return new Request(url, { method, headers, body }); +} + +async function readNodeRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = Buffer.concat(chunks); + return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer; +} + +async function writeFetchResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => res.setHeader(key, value)); + const body = response.body ? Buffer.from(await response.arrayBuffer()) : undefined; + res.end(body); +} + class FetchResponseSink { statusCode = 200; private readonly headers = new Headers(); @@ -509,14 +513,32 @@ class FetchResponseSink { } } -function authenticatedFetchSessionKind(request: Request, sessions: WebConsoleSessionStore): SessionKind | undefined { - const cookie = request.headers.get("cookie") ?? ""; - const token = cookie +async function authenticatedFetchSessionKind( + request: Request, + options: WebConsoleOptions, + sessions: WebConsoleSessionStore, +): Promise { + if (options.sessionSecret) { + return (await verifySignedSessionCookie({ + secret: options.sessionSecret, + cookieHeader: request.headers.get("cookie") ?? "", + now: currentDate(options), + })) ?? undefined; + } + const token = sessionTokenFromCookie(request.headers.get("cookie") ?? ""); + return token ? sessions.get(token) : undefined; +} + +function currentDate(options: WebConsoleOptions): Date { + return options.now?.() ?? new Date(); +} + +function sessionTokenFromCookie(cookie: string): string | undefined { + return cookie .split(";") .map((part) => part.trim()) .find((part) => part.startsWith(`${sessionCookie}=`)) ?.slice(sessionCookie.length + 1); - return token ? sessions.get(token) : undefined; } function jsonResponse(body: unknown, status: number): Response { @@ -534,6 +556,12 @@ function redirectResponse(location: string): Response { return new Response(null, { status: 302, headers: { location } }); } +async function readRequestForm(request: Request): Promise { + const body = Buffer.from(await request.arrayBuffer()); + if (body.length > maxFormBodyBytes) throw new FormBodyTooLargeError("form body too large"); + return new URLSearchParams(body.toString("utf8")); +} + async function renderLogin(res: ServerResponse, settings: AppSettingsService, error = ""): Promise { const hasPassword = Boolean(await settings.get(passwordHashKey)); const label = hasPassword ? "控制台密码" : "首次设置令牌"; @@ -903,28 +931,6 @@ async function loginSessionKind(settings: AppSettingsService, form: URLSearchPar return setupToken && form.get("setupToken") === setupToken ? "setup" : undefined; } -function authenticatedSessionKind(req: IncomingMessage, sessions: Map): SessionKind | undefined { - const cookie = req.headers.cookie ?? ""; - const token = cookie - .split(";") - .map((part) => part.trim()) - .find((part) => part.startsWith(`${sessionCookie}=`)) - ?.slice(sessionCookie.length + 1); - return token ? sessions.get(token) : undefined; -} - -async function readForm(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - size += buffer.length; - if (size > maxFormBodyBytes) throw new FormBodyTooLargeError("form body too large"); - chunks.push(buffer); - } - return new URLSearchParams(Buffer.concat(chunks).toString("utf8")); -} - function hashPassword(password: string): string { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(password, salt, 32).toString("hex"); diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts index db54de6..6bd265b 100644 --- a/src/runtime/worker.ts +++ b/src/runtime/worker.ts @@ -7,8 +7,9 @@ import { AppSettingsService } from "../domain/app-settings.js"; import type { Database } from "../ports/database.js"; import { D1DatabaseAdapter, type D1DatabaseBinding } from "../storage/d1.js"; import { migrate } from "../storage/migrations/0001_initial.js"; -import { loadConfigFromSources, type AppConfig, type ConfigMap } from "./config.js"; +import { configIssues, loadConfigFromSources, type AppConfig, type ConfigMap } from "./config.js"; import { runMaintenanceJobs as defaultRunMaintenanceJobs, type MaintenanceJobsInput, type MaintenanceJobSummary } from "./maintenance.js"; +import { handleWebConsoleRequest } from "./web-console.js"; export interface WorkerEnv { DB: D1DatabaseBinding; @@ -55,9 +56,57 @@ export async function handleWorkerFetch(request: Request, env: WorkerEnv, option return handler(request); } + if (isWebConsolePath(url.pathname)) { + return handleWorkerWebConsoleRequest(request, db, env); + } + return json({ error: "not_found" }, { status: 404 }); } +async function handleWorkerWebConsoleRequest(request: Request, db: Database, env: WorkerEnv): Promise { + const sessionSecret = typeof env.WEB_CONSOLE_SESSION_SECRET === "string" ? env.WEB_CONSOLE_SESSION_SECRET : ""; + if (!sessionSecret) return json({ error: "WEB_CONSOLE_SESSION_SECRET is required" }, { status: 503 }); + const settings = new AppSettingsService(db); + return handleWebConsoleRequest(request, { + settings, + port: 0, + sessionSecret, + getStatus: async () => { + const issues = configIssues(await settings.all(), workerEnvToConfigMap(env)); + return { bot: issues.length ? "stopped" : "running", issues }; + }, + onConfigSaved: async () => {}, + dbHealthCheck: async () => { + await db.prepare("SELECT 1").get(); + return true; + }, + collectMetrics: () => ({ + messages: { inbound_total: 0, outbound_total: 0, internal_total: 0 }, + deliveries: { pending: 0, sent: 0, failed: 0, permanent_failure: 0 }, + conversations: { open: 0, closed: 0 }, + ai_drafts: { pending: 0, ready: 0, failed: 0 }, + uptime_seconds: 0, + timestamp: new Date().toISOString(), + }), + collectOperationsOverview: () => ({ + messages: { inboundTotal: 0, outboundTotal: 0, internalTotal: 0 }, + deliveries: { pending: 0, sent: 0, failed: 0, permanentFailure: 0 }, + conversations: { open: 0, closed: 0 }, + aiDrafts: { pending: 0, ready: 0, failed: 0, sent: 0, discarded: 0 }, + uptimeSeconds: 0, + }), + listConversations: () => ({ items: [], total: 0 }), + listFailedDeliveries: () => ({ items: [], total: 0 }), + scheduleRetry: async () => {}, + listAuditLogs: () => ({ items: [], total: 0 }), + searchMessages: () => ({ items: [], total: 0 }), + }); +} + +function isWebConsolePath(pathname: string): boolean { + return pathname === "/" || pathname === "/login" || pathname === "/logout" || pathname === "/metrics" || pathname.startsWith("/config") || pathname.startsWith("/operations"); +} + export async function handleWorkerScheduled( _controller: WorkerScheduledController, env: WorkerEnv, diff --git a/test/core.test.ts b/test/core.test.ts index d8690b7..a237e03 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -21,6 +21,7 @@ import { migrate } from "../src/storage/migrations/0001_initial.js"; import { runMigration } from "../src/storage/migrations/runner.js"; import { runMaintenanceJobs } from "../src/runtime/maintenance.js"; import { handleWorkerFetch, handleWorkerScheduled, workerEnvToConfigMap, type WorkerEnv } from "../src/runtime/worker.js"; +import { createSignedSessionCookie, verifySignedSessionCookie } from "../src/runtime/web-console-session.js"; import { createWorkerTelegramWebhookHandler } from "../src/channels/telegram/worker-webhook.js"; import { buildTopicName } from "../src/channels/telegram/topics.js"; import { detectMessageType, extractText, summarizeTelegramMessage } from "../src/channels/telegram/media.js"; @@ -520,8 +521,67 @@ describe("Workers runtime", () => { assert.ok(calls.some((call) => call.sql === "SELECT key, value FROM app_settings")); assert.deepEqual(summaries, [{ config: "token", api: { token: "token" } }]); }); + + it("serves Web Console login from the Worker runtime", async () => { + const env: WorkerEnv = { + DB: createD1TestBinding(), + WEB_CONSOLE_SESSION_SECRET: "session-secret-value", + TELEGRAM_BOT_TOKEN: "token", + TELEGRAM_MANAGEMENT_CHAT_ID: "-1001", + TELEGRAM_ADMIN_USER_IDS: "1", + }; + + const response = await handleWorkerFetch(new Request("https://example.com/login"), env); + + assert.equal(response.status, 200); + assert.match(await response.text(), /登录控制台/); + }); + + it("keeps the Worker Telegram webhook route separate from Web Console routing", async () => { + const env: WorkerEnv = { + DB: createD1TestBinding(), + WEB_CONSOLE_SESSION_SECRET: "session-secret-value", + TELEGRAM_BOT_TOKEN: "token", + TELEGRAM_MANAGEMENT_CHAT_ID: "-1001", + TELEGRAM_ADMIN_USER_IDS: "1", + }; + const request = new Request("https://example.com/telegram/webhook", { method: "POST" }); + + const response = await handleWorkerFetch(request, env, { + telegramWebhookHandler: async () => new Response("telegram", { status: 202 }), + }); + + assert.equal(response.status, 202); + assert.equal(await response.text(), "telegram"); + }); }); +function createD1TestBinding(): WorkerEnv["DB"] { + return { + prepare(sql: string) { + return { + bind(..._params: SqlValue[]) { + return { + async run() { + return { meta: { changes: 0 } }; + }, + async first() { + return undefined; + }, + async all() { + if (sql === "SELECT key, value FROM app_settings") return { results: [] }; + return { results: [] }; + }, + }; + }, + async run() { + return { meta: { changes: 0 } }; + }, + }; + }, + }; +} + describe("runtime maintenance", () => { it("runs conversation expiry and message retention jobs", async () => { const events: string[] = []; @@ -588,6 +648,40 @@ describe("Workers Telegram webhook", () => { }); describe("web console", () => { + it("signs and verifies Web Console cookies without server memory", async () => { + const cookie = await createSignedSessionCookie({ + secret: "session-secret-value", + kind: "password", + now: new Date("2026-07-01T00:00:00.000Z"), + maxAgeSeconds: 3600, + }); + + const verified = await verifySignedSessionCookie({ + secret: "session-secret-value", + cookieHeader: cookie, + now: new Date("2026-07-01T00:10:00.000Z"), + }); + + assert.equal(verified, "password"); + }); + + it("rejects expired Web Console signed cookies", async () => { + const cookie = await createSignedSessionCookie({ + secret: "session-secret-value", + kind: "password", + now: new Date("2026-07-01T00:00:00.000Z"), + maxAgeSeconds: 60, + }); + + const verified = await verifySignedSessionCookie({ + secret: "session-secret-value", + cookieHeader: cookie, + now: new Date("2026-07-01T00:02:00.000Z"), + }); + + assert.equal(verified, null); + }); + it("handles login and health checks through Fetch requests", async () => { const settings = new AppSettingsService(handle.db); await settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); @@ -618,6 +712,98 @@ describe("web console", () => { assert.deepEqual(body, { status: "ok", bot: "running", db: "reachable" }); }); + it("serves authenticated Web Console pages through Fetch requests", async () => { + const settings = new AppSettingsService(handle.db); + await settings.setMany({ WEB_CONSOLE_PASSWORD_HASH: "bad:hash" }); + const sessions = new Map([["session-id", "password" as const]]); + const options = { + settings, + port: 0, + getStatus: () => ({ bot: "running" as const, issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: async () => true, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, + }; + + const overview = await handleWebConsoleRequest( + new Request("https://example.com/", { headers: { cookie: "inboxbridge_session=session-id" } }), + options, + sessions, + ); + const config = await handleWebConsoleRequest( + new Request("https://example.com/config", { headers: { cookie: "inboxbridge_session=session-id" } }), + options, + sessions, + ); + const operations = await handleWebConsoleRequest( + new Request("https://example.com/operations", { headers: { cookie: "inboxbridge_session=session-id" } }), + options, + sessions, + ); + + assert.equal(overview.status, 200); + assert.match(await overview.text(), /控制台概览/); + assert.equal(config.status, 200); + assert.match(await config.text(), /配置仪表盘/); + assert.equal(operations.status, 200); + assert.match(await operations.text(), /运维仪表盘/); + }); + + it("authenticates and logs out Web Console sessions through Fetch requests", async () => { + const settings = new AppSettingsService(handle.db); + await settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); + const sessions = new Map(); + const options = { + settings, + port: 0, + getStatus: () => ({ bot: "running" as const, issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: async () => true, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, + }; + + const login = await handleWebConsoleRequest( + new Request("https://example.com/login", { + method: "POST", + body: new URLSearchParams({ setupToken: "setup-token" }), + }), + options, + sessions, + ); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + + assert.equal(login.status, 302); + assert.equal(login.headers.get("location"), "/"); + assert.match(cookie, /^inboxbridge_session=/); + assert.equal(sessions.size, 1); + + const logout = await handleWebConsoleRequest( + new Request("https://example.com/logout", { + method: "POST", + headers: { cookie }, + }), + options, + sessions, + ); + + assert.equal(logout.status, 302); + assert.equal(logout.headers.get("location"), "/login"); + assert.equal(sessions.size, 0); + assert.match(logout.headers.get("set-cookie") ?? "", /Max-Age=0/); + }); + it("requires a password before setup-token sessions can save configuration", async () => { const settings = new AppSettingsService(handle.db); await settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); @@ -957,6 +1143,47 @@ describe("web console", () => { server.close(); } }); + + it("handles Node Web Console logout through the shared Fetch handler", async () => { + const settings = new AppSettingsService(handle.db); + await settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); + const server = await startWebConsole({ + settings, + port: 0, + getStatus: () => ({ bot: "running", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, + }); + const port = (server.address() as AddressInfo).port; + try { + const loginRes = await fetch(`http://127.0.0.1:${port}/login`, { + method: "POST", + body: new URLSearchParams({ setupToken: "setup-token" }), + redirect: "manual", + }); + const cookie = loginRes.headers.get("set-cookie")?.split(";")[0]; + assert.ok(cookie); + + const logoutRes = await fetch(`http://127.0.0.1:${port}/logout`, { + method: "POST", + headers: { cookie }, + redirect: "manual", + }); + + assert.equal(logoutRes.status, 302); + assert.equal(logoutRes.headers.get("location"), "/login"); + assert.match(logoutRes.headers.get("set-cookie") ?? "", /Max-Age=0/); + } finally { + server.close(); + } + }); }); describe("conversation service", () => { diff --git a/wrangler.toml b/wrangler.toml index 27512e4..320bf24 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,6 @@ name = "inboxbridge" main = "src/runtime/worker.ts" -compatibility_date = "2026-06-30" +compatibility_date = "2026-07-01" [triggers] crons = ["*/15 * * * *"]