From b0304d0dfbdd1a988c4e41dce9c5fe64bfc6011e Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:52:18 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(dev):=20local=20dev=20API=20harness=20?= =?UTF-8?q?=E2=80=94=20runnable=20createInboxApi=20with=20seeded=20data=20?= =?UTF-8?q?(HT-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package-lock.json | 504 ++++++++++++++++++++++++++++++++++++++++ package.json | 4 +- scripts/dev-api.ts | 127 ++++++++++ src/dev/dev-sender.ts | 40 ++++ src/dev/http-adapter.ts | 100 ++++++++ src/dev/seed.test.ts | 84 +++++++ src/dev/seed.ts | 270 +++++++++++++++++++++ 7 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 scripts/dev-api.ts create mode 100644 src/dev/dev-sender.ts create mode 100644 src/dev/http-adapter.ts create mode 100644 src/dev/seed.test.ts create mode 100644 src/dev/seed.ts diff --git a/package-lock.json b/package-lock.json index b4c00fd..af416be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^20.19.43", "@types/pg": "^8.20.0", "@vitest/coverage-v8": "4.1.10", + "tsx": "^4.23.0", "typescript": "7.0.2", "vitest": "4.1.10" }, @@ -420,6 +421,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1375,6 +1818,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2220,6 +2705,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index dfc17df..d59e819 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format": "biome format --write .", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "dev:api": "tsx scripts/dev-api.ts" }, "dependencies": { "@electric-sql/pglite": "^0.5.4", @@ -29,6 +30,7 @@ "@types/node": "^20.19.43", "@types/pg": "^8.20.0", "@vitest/coverage-v8": "4.1.10", + "tsx": "^4.23.0", "typescript": "7.0.2", "vitest": "4.1.10" } diff --git a/scripts/dev-api.ts b/scripts/dev-api.ts new file mode 100644 index 0000000..2eaa485 --- /dev/null +++ b/scripts/dev-api.ts @@ -0,0 +1,127 @@ +/** + * Local dev API harness (HT-24) — the first runnable entry point for the + * Helpthread engine. + * + * Run via `npm run dev:api` (tsx, a devDependency — see package.json). + * Deliberately NOT part of the checked TypeScript project: `tsconfig.json`'s + * `include` only covers `src/**` and `tests/**`, so this file (and the rest + * of `scripts/`) is outside `tsc`'s project and outside anything that would + * ever ship — this is dev tooling, not engine code. The reusable pieces it + * wires together (`src/dev/**`) DO live under `src/` and stay normally + * typechecked/linted/tested, same as the rest of the engine. + * + * What this does: creates a PGlite `Db` (in-memory by default, optionally + * file-backed for persistence across restarts), runs migrations, seeds demo + * conversations (in-memory mode only), builds the real `createInboxApi` + * (`src/api/index.ts`) with a dev-only `EmailSender` that logs instead of + * delivering, and serves it over plain `node:http` via the hand-rolled + * bridge in `src/dev/http-adapter.ts`. This is the integration target for + * the upcoming Agent Inbox UI (HT-23) and a standing dogfood surface for the + * engine in the meantime. + * + * ## Configuration (env vars) + * + * - `HT_DEV_TOKEN` — the Bearer token every request must carry. Defaults to + * the clearly-dev-only `helpthread-dev-token` (`createInboxApi` requires + * at least 16 characters — see `MIN_API_TOKEN_LENGTH`, `src/api/index.ts` + * — so the default is chosen to clear that floor) — never reuse this + * default outside a local machine. + * - `HT_DEV_PORT` — HTTP port to listen on. Defaults to `8787`. + * - `HT_DEV_DB_PATH` — optional PGlite data directory. Omitted (the default) + * runs a fresh in-memory database, seeded with demo conversations on every + * boot. Set to persist data across restarts — in that mode seeding is + * skipped, since the whole point is that the data survives. + */ + +import { createServer } from 'node:http' +import { createInboxApi } from '../src/api/index.js' +import { createPgliteDb } from '../src/db/client.js' +import { migrate } from '../src/db/migrate.js' +import { createDevEmailSender } from '../src/dev/dev-sender.js' +import { createHttpBridge } from '../src/dev/http-adapter.js' +import { seedDevData } from '../src/dev/seed.js' +import type { Keyring } from '../src/mail/reply-token.js' +import { createConversationStore } from '../src/store/conversations.js' + +const PORT = Number(process.env.HT_DEV_PORT ?? 8787) +const API_TOKEN = process.env.HT_DEV_TOKEN ?? 'helpthread-dev-token' +const DB_PATH = process.env.HT_DEV_DB_PATH +const MAIL_DOMAIN = 'mail.dev.localhost' +const SUPPORT_ADDRESS = 'support@dev.localhost' + +// A fixed dev-only signing key — never used outside this local harness. A +// real deployment must supply its own high-entropy secret (see +// src/mail/reply-token.ts's MIN_SECRET_LENGTH, 32 chars minimum). +const KEYRING: Keyring = { + current: { keyId: 'dev', secret: 'dev-only-signing-secret-not-for-production-use' }, +} + +async function main(): Promise { + const db = await createPgliteDb(DB_PATH !== undefined ? { dataDir: DB_PATH } : undefined) + await migrate(db) + const store = createConversationStore(db) + const sender = createDevEmailSender() + + let seededCount: number | undefined + if (DB_PATH === undefined) { + const seeded = await seedDevData({ + db, + store, + sender, + keyring: KEYRING, + mailDomain: MAIL_DOMAIN, + supportAddress: SUPPORT_ADDRESS, + }) + seededCount = seeded.conversationCount + } + + const api = createInboxApi({ + store, + apiToken: API_TOKEN, + sender, + keyring: KEYRING, + mailDomain: MAIL_DOMAIN, + supportAddress: SUPPORT_ADDRESS, + }) + + const baseUrl = `http://localhost:${PORT}` + const server = createServer(createHttpBridge(api, baseUrl)) + await new Promise((resolve) => { + server.listen(PORT, resolve) + }) + + console.log('') + console.log(`Helpthread dev API listening at ${baseUrl}`) + console.log(` Auth token: ${API_TOKEN}`) + console.log( + ` Database: ${DB_PATH !== undefined ? `file-backed (${DB_PATH})` : 'in-memory (reset on every restart)'}`, + ) + console.log( + ` Seeded: ${seededCount !== undefined ? `${seededCount} conversations` : 'skipped (file-backed db keeps its existing data)'}`, + ) + console.log('') + console.log('Example requests:') + console.log(` curl -s ${baseUrl}/api/v1/conversations \\`) + console.log(` -H "Authorization: Bearer ${API_TOKEN}"`) + console.log('') + console.log(` curl -s -X POST ${baseUrl}/api/v1/conversations//replies \\`) + console.log(` -H "Authorization: Bearer ${API_TOKEN}" \\`) + console.log(' -H "Content-Type: application/json" \\') + console.log(' -H "Idempotency-Key: " \\') + console.log(' -d \'{"text": "Thanks for reaching out!"}\'') + console.log('') + + const shutdown = async (): Promise => { + console.log('\n[dev-api] shutting down...') + server.close() + await db.close() + process.exit(0) + } + process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown()) +} + +main().catch((err: unknown) => { + console.error('[dev-api] fatal error starting server', err) + process.exit(1) +}) diff --git a/src/dev/dev-sender.ts b/src/dev/dev-sender.ts new file mode 100644 index 0000000..5b6e708 --- /dev/null +++ b/src/dev/dev-sender.ts @@ -0,0 +1,40 @@ +/** + * A dev-only `EmailSender` (`src/providers/email-sender.ts`) for the local + * API harness (HT-24): it delivers nothing, ever — no provider, no network + * call — and instead logs the full `OutboundEmail` to stdout, so a developer + * running `npm run dev:api` can see exactly what the engine would have sent. + * + * `maxSendMs` is set well below `DEFAULT_LEASE_MS` (`src/mail/send.ts`, + * 120 000ms) — required by the `EmailSender` contract (see that interface's + * doc comment) so `assertLeaseExceedsSenderBound` never rejects this sender + * at a claim site. `5_000` is arbitrary but generous for a synchronous + * console write that never actually waits on I/O. + */ + +import type { EmailSender, EmailSendResult, OutboundEmail } from '../providers/index.js' + +/** See the module doc: comfortably below `DEFAULT_LEASE_MS`, and irrelevant in practice since `send` never actually waits on anything. */ +const DEV_SENDER_MAX_SEND_MS = 5_000 + +/** + * Build the dev `EmailSender`. Every call resolves immediately with an empty + * result (no `providerMessageId` — there is no provider) after logging the + * recipient(s), subject, and `Message-ID` (the reply-token contract every + * `EmailSender` must transmit verbatim — see the interface doc) to stdout. + */ +export function createDevEmailSender(): EmailSender { + return { + maxSendMs: DEV_SENDER_MAX_SEND_MS, + + async send(email: OutboundEmail): Promise { + console.log('[dev-sender] would send (nothing actually delivered):') + console.log(` To: ${email.to.join(', ')}`) + if (email.cc !== undefined && email.cc.length > 0) { + console.log(` Cc: ${email.cc.join(', ')}`) + } + console.log(` Subject: ${email.subject}`) + console.log(` Message-ID: ${email.messageId}`) + return {} + }, + } +} diff --git a/src/dev/http-adapter.ts b/src/dev/http-adapter.ts new file mode 100644 index 0000000..9a031c9 --- /dev/null +++ b/src/dev/http-adapter.ts @@ -0,0 +1,100 @@ +/** + * A hand-rolled bridge between `node:http` and the web-standard + * `Request`/`Response` pair `createInboxApi` (`src/api/index.ts`) is built + * on — the whole point of that module's framework-agnostic design (its own + * doc comment: "a Vercel/Next.js route is a thin deploy-time wrapper, not + * part of this spec"). This is the "plain Node HTTP server" wrapper it + * anticipates, written for the local dev harness (HT-24) rather than + * pulling in a web framework: the API surface is exactly one handler + * function, so a full framework (Express, Fastify, …) would be a dependency + * spent on a problem two small functions already solve. + * + * No new runtime dependency: both directions are built from Node's own + * `node:http` and `node:stream` plus the global `Request`/`Response` + * (undici, bundled in Node since 18). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' + +/** + * Build a web-standard `Request` from an incoming Node request. `baseUrl` + * supplies the scheme/host/port that `IncomingMessage.url` (which is only + * ever a path, per the HTTP/1.1 request line) doesn't carry — `new URL` + * needs an absolute base to resolve it against. + * + * `GET`/`HEAD` requests are given no body at all (rather than an empty + * stream) — the Fetch `Request` constructor rejects a body on either + * method. Every other method streams the incoming request body straight + * through via `Readable.toWeb`, with `duplex: 'half'` — required by the + * Fetch spec (and enforced by undici) whenever a `Request`'s body is a + * stream rather than a buffered value. + */ +export function toWebRequest(req: IncomingMessage, baseUrl: string): Request { + const url = new URL(req.url ?? '/', baseUrl) + + const headers = new Headers() + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue + for (const v of Array.isArray(value) ? value : [value]) { + headers.append(name, v) + } + } + + const method = req.method ?? 'GET' + const hasBody = method !== 'GET' && method !== 'HEAD' + + const init: RequestInit & { duplex?: 'half' } = { method, headers } + if (hasBody) { + init.body = Readable.toWeb(req) as unknown as ReadableStream + init.duplex = 'half' + } + + return new Request(url, init) +} + +/** + * Write a web-standard `Response` back onto a Node `ServerResponse`: status, + * every header, then the fully-buffered body. Buffered (via `arrayBuffer()`) + * rather than streamed — every response this API ever produces + * (`src/api/responses.ts`) is a small JSON envelope, so the simplicity of + * "read it all, write it once" costs nothing in practice and avoids a second + * stream-plumbing path alongside {@link toWebRequest}'s. + */ +export async function sendWebResponse(response: Response, res: ServerResponse): Promise { + res.statusCode = response.status + response.headers.forEach((value, name) => { + res.setHeader(name, value) + }) + const body = Buffer.from(await response.arrayBuffer()) + res.end(body) +} + +/** + * Adapt a `(Request) => Promise` handler (i.e. `createInboxApi`'s + * return value) into the `(req, res) => void` shape `node:http`'s + * `createServer` expects. Any error the handler throws (it shouldn't — + * `createInboxApi` catches its own internals — but a bridge-layer bug, e.g. + * a body-stream error, is still possible) is logged and answered with a bare + * 500, never left to hang the connection or crash the process. + */ +export function createHttpBridge( + handler: (request: Request) => Promise, + baseUrl: string, +): (req: IncomingMessage, res: ServerResponse) => void { + return (req, res) => { + void (async () => { + try { + const request = toWebRequest(req, baseUrl) + const response = await handler(request) + await sendWebResponse(response, res) + } catch (err) { + console.error('[dev-api] request bridge error', err) + if (!res.headersSent) { + res.statusCode = 500 + } + res.end() + } + })() + } +} diff --git a/src/dev/seed.test.ts b/src/dev/seed.test.ts new file mode 100644 index 0000000..c3ceed9 --- /dev/null +++ b/src/dev/seed.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createPgliteDb, type Db } from '../db/client.js' +import { migrate } from '../db/migrate.js' +import type { Keyring } from '../mail/reply-token.js' +import type { EmailSender, OutboundEmail } from '../providers/index.js' +import { createConversationStore } from '../store/conversations.js' +import { seedDevData } from './seed.js' + +const KEYRING: Keyring = { current: { keyId: 'k1', secret: 'a'.repeat(32) } } +const MAIL_DOMAIN = 'mail.example.test' +const SUPPORT_ADDRESS = 'support@example.test' + +/** A recording `EmailSender` that never fails — for counting real sends the dev sender would have logged. */ +function createRecordingSender(): { sender: EmailSender; sent: OutboundEmail[] } { + const sent: OutboundEmail[] = [] + return { + sender: { + maxSendMs: 5_000, + async send(email) { + sent.push(email) + return {} + }, + }, + sent, + } +} + +describe('seedDevData', () => { + let db: Db | undefined + + afterEach(async () => { + await db?.close() + db = undefined + }) + + it('seeds every conversation/delivery-state variety the dev harness promises', async () => { + db = await createPgliteDb() + await migrate(db) + const store = createConversationStore(db) + const { sender, sent } = createRecordingSender() + + const result = await seedDevData({ + db, + store, + sender, + keyring: KEYRING, + mailDomain: MAIL_DOMAIN, + supportAddress: SUPPORT_ADDRESS, + }) + + expect(result.conversationCount).toBe(6) + + // Real sends went through the injected sender for every reply EXCEPT the + // one deliberately routed through the seed's own failing sender — see + // seed.ts's "failed" demo. + expect(sent.length).toBe(4) + + const open = await store.listConversations({ limit: 50, status: 'open' }) + const closed = await store.listConversations({ limit: 50, status: 'closed' }) + expect(open).toHaveLength(5) + expect(closed).toHaveLength(1) + + const allThreads = await Promise.all( + [...open, ...closed].map((c) => store.getConversation(c.id, { includeDeleted: false })), + ) + const deliveryStatuses = allThreads + .flatMap((c) => c?.threads ?? []) + .map((t) => t.deliveryStatus) + .filter((s): s is 'pending' | 'sent' | 'failed' => s !== null) + + expect(deliveryStatuses).toContain('sent') + expect(deliveryStatuses).toContain('failed') + expect(deliveryStatuses).toContain('pending') + + // The stale-pending demo's row must actually be old enough for the + // delivery worker's default staleAfterMs window (src/mail/delivery-worker.ts) — + // otherwise it's indistinguishable from an ordinary in-flight send. + const pendingThread = allThreads + .flatMap((c) => c?.threads ?? []) + .find((t) => t.deliveryStatus === 'pending') + expect(pendingThread).toBeDefined() + expect(Date.now() - (pendingThread?.createdAt.getTime() ?? 0)).toBeGreaterThan(5 * 60_000) + }) +}) diff --git a/src/dev/seed.ts b/src/dev/seed.ts new file mode 100644 index 0000000..bfe11a3 --- /dev/null +++ b/src/dev/seed.ts @@ -0,0 +1,270 @@ +/** + * Dev seed data for the local API harness (HT-24) — enough variety in one + * boot for the upcoming Agent Inbox UI (HT-23) to exercise every state it + * must render: an inbound-only conversation, a threaded back-and-forth, one + * outbound thread in each delivery state (`sent`/`failed`/stale `pending`), + * and a closed conversation. Every name and message below is invented for + * this seed — never real customer data (CLAUDE.md). + * + * Reuses the real engine paths wherever practical rather than raw SQL, so + * seeding itself exercises the store and the send pipeline: + * `ConversationStore.createConversation`/`appendThread` for inbound + * messages, and `sendReply` (`src/mail/send.ts` — the same + * mint→persist→send→mark path `POST .../replies` uses) for outbound ones. + * The one deliberate exception is the "stale pending" demo, documented at + * its call site below. + */ + +import { randomUUID } from 'node:crypto' +import type { Db } from '../db/client.js' +import type { Keyring } from '../mail/reply-token.js' +import { mintReplyMessageId } from '../mail/reply-token.js' +import { sendReply } from '../mail/send.js' +import type { EmailSender } from '../providers/index.js' +import type { ConversationStore } from '../store/conversations.js' + +export interface SeedDevDataDeps { + db: Db + store: ConversationStore + /** The dev sender every SUCCESSFUL seed reply is sent through (logs to stdout, delivers nothing). */ + sender: EmailSender + keyring: Keyring + mailDomain: string + supportAddress: string +} + +export interface SeedDevDataResult { + conversationCount: number +} + +/** + * A sender that always rejects — used for exactly one seed reply, so that + * conversation's outbound thread lands in the real `'failed'` delivery + * state via the engine's own send-failure handling (`sendReply` marks the + * thread `'failed'` when the provider throws), rather than being faked up + * with a raw status write. + */ +const FAILING_SEED_SENDER: EmailSender = { + maxSendMs: 5_000, + async send() { + throw new Error('dev seed: simulated provider rejection (the "failed" delivery-state demo)') + }, +} + +/** + * Seed the demo conversations. Safe to call once against a fresh (just- + * migrated, empty) database — every conversation is newly created, so a + * second call would simply add a second copy of each rather than erroring, + * but the dev harness only ever calls this once per fresh in-memory boot + * (`scripts/dev-api.ts`). + */ +export async function seedDevData(deps: SeedDevDataDeps): Promise { + const { db, store, sender, keyring, mailDomain, supportAddress } = deps + let conversationCount = 0 + + // --- 1. Inbound-only: a customer message with no reply yet. --------------- + await store.createConversation({ + subject: "Can't log into my account", + customerEmail: 'mia.chen@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'mia.chen@example.test', + bodyText: + "Hi, I've tried resetting my password twice and I'm still locked out. Can you help?", + }, + }) + conversationCount++ + + // --- 2. Threaded back-and-forth: inbound, outbound (sent), inbound, outbound (sent). --- + const threaded = await store.createConversation({ + subject: 'Refund request for order #4821', + customerEmail: 'devon.brooks@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'devon.brooks@example.test', + bodyText: "Hi, I'd like a refund for order #4821 — it arrived damaged.", + }, + }) + conversationCount++ + const threadedReply1 = await sendReply( + { + conversationId: threaded.conversationId, + from: supportAddress, + to: ['devon.brooks@example.test'], + subject: 'Re: Refund request for order #4821', + text: 'Sorry to hear that! Could you send a photo of the damage so we can process the refund?', + inReplyTo: '', + references: [''], + idempotencyKey: 'seed-devon-brooks-reply-1', + }, + { store, sender, keyring, mailDomain }, + ) + if (!threadedReply1.ok) { + throw new Error("seed: expected the threaded demo's first reply to send successfully") + } + await store.appendThread(threaded.conversationId, { + direction: 'inbound', + messageId: '', + inReplyTo: threadedReply1.messageId, + fromAddress: 'devon.brooks@example.test', + bodyText: 'Sure — attached is a photo of the damaged packaging.', + }) + const threadedReply2 = await sendReply( + { + conversationId: threaded.conversationId, + from: supportAddress, + to: ['devon.brooks@example.test'], + subject: 'Re: Refund request for order #4821', + text: 'Thanks for the photo — refund processed, should appear in 3-5 business days.', + inReplyTo: '', + references: [ + '', + threadedReply1.messageId, + '', + ], + idempotencyKey: 'seed-devon-brooks-reply-2', + }, + { store, sender, keyring, mailDomain }, + ) + if (!threadedReply2.ok) { + throw new Error("seed: expected the threaded demo's second reply to send successfully") + } + + // --- 3. Outbound delivery state: 'sent'. ----------------------------------- + const sentDemo = await store.createConversation({ + subject: 'Question about Pro plan pricing', + customerEmail: 'priya.natarajan@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'priya.natarajan@example.test', + bodyText: "What's included in the Pro plan, and is there a seat minimum?", + }, + }) + conversationCount++ + await sendReply( + { + conversationId: sentDemo.conversationId, + from: supportAddress, + to: ['priya.natarajan@example.test'], + subject: 'Re: Question about Pro plan pricing', + text: 'Pro includes unlimited shared inboxes and no seat minimum — happy to send the full comparison if useful!', + inReplyTo: '', + references: [''], + idempotencyKey: 'seed-priya-natarajan-reply-1', + }, + { store, sender, keyring, mailDomain }, + ) + + // --- 4. Outbound delivery state: 'failed'. --------------------------------- + const failedDemo = await store.createConversation({ + subject: 'Shipping delay on order #77', + customerEmail: 'sam.oyelaran@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'sam.oyelaran@example.test', + bodyText: "My order #77 hasn't shipped yet and it's been over a week. What's going on?", + }, + }) + conversationCount++ + // Sent through FAILING_SEED_SENDER instead of the dev sender: sendReply's + // own failure handling (src/mail/send.ts) is what marks this thread + // 'failed' — a genuine engine-produced failure, not a hand-set status. + await sendReply( + { + conversationId: failedDemo.conversationId, + from: supportAddress, + to: ['sam.oyelaran@example.test'], + subject: 'Re: Shipping delay on order #77', + text: "We're sorry for the delay — checking with the warehouse now and will follow up shortly.", + inReplyTo: '', + references: [''], + idempotencyKey: 'seed-sam-oyelaran-reply-1', + }, + { store, sender: FAILING_SEED_SENDER, keyring, mailDomain }, + ) + + // --- 5. Outbound delivery state: stale 'pending'. -------------------------- + const staleDemo = await store.createConversation({ + subject: 'Question about API rate limits', + customerEmail: 'jordan.kwame@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'jordan.kwame@example.test', + bodyText: 'What are the current API rate limits for the REST endpoints?', + }, + }) + conversationCount++ + // Modeling a crash between persist and send (specs/mail/sending.md §3: the + // ordering that leaves a thread truthfully 'pending' rather than falsely + // 'sent') is not something `sendReply` itself can produce on demand — it + // always resolves to 'sent' or 'failed'. So this row is persisted directly + // via `ConversationStore.appendThread` (a real store call, not raw SQL), + // with a properly minted Message-ID and a send envelope, exactly as + // `sendReply` would have left it mid-flight. + const staleThreadId = randomUUID() + const staleMessageId = mintReplyMessageId( + { conversationId: staleDemo.conversationId, threadId: staleThreadId, mailDomain }, + keyring, + ) + const staleAppended = await store.appendThread(staleDemo.conversationId, { + id: staleThreadId, + direction: 'outbound', + messageId: staleMessageId, + inReplyTo: '', + fromAddress: supportAddress, + bodyText: "We're pulling together the current limits for you now.", + deliveryStatus: 'pending', + sendEnvelope: { + to: ['jordan.kwame@example.test'], + subject: 'Re: Question about API rate limits', + references: [''], + }, + }) + if (!staleAppended.ok) { + throw new Error("seed: expected the stale-pending demo's thread to persist") + } + // Backdate just this one row so it reads as genuinely STALE — older than + // the delivery worker's default 5-minute staleAfterMs + // (`DEFAULT_STALE_AFTER_MS`, src/mail/delivery-worker.ts) — rather than a + // send that merely hasn't resolved yet. There is no `ConversationStore` + // method for setting `created_at` (no real code path ever backdates a + // row), so this is the one deliberate raw-SQL step in this seed script, + // scoped to exactly the row it demonstrates. + await db.query("UPDATE threads SET created_at = now() - interval '1 hour' WHERE id = $1", [ + staleThreadId, + ]) + + // --- 6. Closed conversation. ------------------------------------------------ + const closedDemo = await store.createConversation({ + subject: 'Thanks for the help!', + customerEmail: 'elena.vasquez@example.test', + firstMessage: { + direction: 'inbound', + messageId: '', + fromAddress: 'elena.vasquez@example.test', + bodyText: 'Just wanted to say thanks for resolving my login issue last week!', + }, + }) + conversationCount++ + await sendReply( + { + conversationId: closedDemo.conversationId, + from: supportAddress, + to: ['elena.vasquez@example.test'], + subject: 'Re: Thanks for the help!', + text: "You're very welcome! Glad it's all sorted now.", + inReplyTo: '', + references: [''], + idempotencyKey: 'seed-elena-vasquez-reply-1', + }, + { store, sender, keyring, mailDomain }, + ) + await store.setConversationStatus(closedDemo.conversationId, 'closed') + + return { conversationCount } +} From 01979d64bbe2ab8ff37dfab3e9d98863817e8a91 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:04:52 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(dev):=20harness=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20loopback=20bind,=20drain-then-close,=20strict=20see?= =?UTF-8?q?ding=20(CodeRabbit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- scripts/dev-api.ts | 11 ++++++++--- src/dev/seed.test.ts | 9 ++++++--- src/dev/seed.ts | 10 ++++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/dev-api.ts b/scripts/dev-api.ts index 2eaa485..6b54a3d 100644 --- a/scripts/dev-api.ts +++ b/scripts/dev-api.ts @@ -84,10 +84,12 @@ async function main(): Promise { supportAddress: SUPPORT_ADDRESS, }) - const baseUrl = `http://localhost:${PORT}` + const baseUrl = `http://127.0.0.1:${PORT}` const server = createServer(createHttpBridge(api, baseUrl)) + // Bind explicitly to loopback — this dev harness must never listen on the + // LAN (the default token is public knowledge, right there in this file). await new Promise((resolve) => { - server.listen(PORT, resolve) + server.listen(PORT, '127.0.0.1', resolve) }) console.log('') @@ -113,7 +115,10 @@ async function main(): Promise { const shutdown = async (): Promise => { console.log('\n[dev-api] shutting down...') - server.close() + // Wait for in-flight requests to drain before closing the db/exiting. + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }) await db.close() process.exit(0) } diff --git a/src/dev/seed.test.ts b/src/dev/seed.test.ts index c3ceed9..0c0c72f 100644 --- a/src/dev/seed.test.ts +++ b/src/dev/seed.test.ts @@ -68,9 +68,12 @@ describe('seedDevData', () => { .map((t) => t.deliveryStatus) .filter((s): s is 'pending' | 'sent' | 'failed' => s !== null) - expect(deliveryStatuses).toContain('sent') - expect(deliveryStatuses).toContain('failed') - expect(deliveryStatuses).toContain('pending') + // Exact distribution: the threaded demo's 2 replies + the lone 'sent' + // demo's reply + the closed demo's reply = 4 'sent'; 1 'failed' (the + // simulated provider rejection); 1 'pending' (the backdated stale demo). + expect(deliveryStatuses.filter((s) => s === 'sent')).toHaveLength(4) + expect(deliveryStatuses.filter((s) => s === 'failed')).toHaveLength(1) + expect(deliveryStatuses.filter((s) => s === 'pending')).toHaveLength(1) // The stale-pending demo's row must actually be old enough for the // delivery worker's default staleAfterMs window (src/mail/delivery-worker.ts) — diff --git a/src/dev/seed.ts b/src/dev/seed.ts index bfe11a3..7cae61a 100644 --- a/src/dev/seed.ts +++ b/src/dev/seed.ts @@ -144,7 +144,7 @@ export async function seedDevData(deps: SeedDevDataDeps): Promise