diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cecacd9..be74e7a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,33 @@ jobs: - name: Build run: pnpm exec turbo run build --filter ansari-frontend... + # Python WhatsApp service (spec 321). Not part of the pnpm/turbo graph, so it + # gets its own job. conftest.py supplies mock-mode defaults, so no env or + # repository secrets are needed. + whatsapp: + name: whatsapp (lint, format, test) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: legacy/whatsapp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + + - name: Install + run: uv sync --frozen --extra dev + + - name: Lint + run: uv run ruff check . + + - name: Format check + run: uv run ruff format --check . + + - name: Test + run: uv run pytest tests/ -v + gitleaks: name: gitleaks (secret scan) runs-on: ubuntu-latest diff --git a/.gitleaks.toml b/.gitleaks.toml index 1f22d22b..3bfa73c7 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -19,4 +19,12 @@ regexes = [ # check while the test drives sign-up/sign-in against pglite. Literal string, # not a credential; trips generic-api-key purely on length + the "secret" key. '''test-secret-at-least-32-characters-long-000''', + # Spec 329 probe scripts, in branch history only: packages/auth/scripts/probes/probe-db.ts + # (commit 90aff8c) and packages/auth/scripts/probes/exchange-lock-probe.ts (commit 427f8fd). + # Synthetic BETTER_AUTH_SECRET and RESEND_API_KEY values, set on process.env so createAuth() + # passes its zod checks for probes run against a throwaway local Postgres. Literal strings, + # not credentials; adjudicated by the security architect, spec 329. Exact full literals, + # never a prefix or a pattern. The tip uses setAuthEnv's placeholders instead. + '''probe-secret-at-least-32-characters-long-00''', + '''re_probe_key_at_least_20_chars''', ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbae77a5..21e97d04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,9 +128,10 @@ You can still target one package directly — `pnpm api lint`, `pnpm frontend we - **Branch from `develop` and target `develop`** — not `main`. `main` is production and is only ever fast-forwarded to `develop` during a release (see [RELEASE.md](RELEASE.md)), so PRs against it can't be merged. -- CI must be green before a PR can merge. `develop` requires all four checks: +- CI must be green before a PR can merge. `develop` requires all five checks: `api (lint, typecheck, test, build)`, `auth (lint, typecheck, test, build)`, - `frontend (lint, typecheck)`, and `gitleaks (secret scan)`. The + `frontend (lint, typecheck)`, `whatsapp (lint, format, test)`, and + `gitleaks (secret scan)`. The [Checks](#checks) above are the same tasks, so passing them locally is a good predictor. If the secret scan fails, see [Secret scanning](#secret-scanning). - Stage files explicitly (`git add path/to/file`). Please don't use `git add -A` diff --git a/RELEASE.md b/RELEASE.md index 36981973..358de981 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,8 +29,8 @@ migration runbook were both proven in real releases on 2026-08-02. > either half on its own. > ### Railway service configuration (spec 48 — monorepo layout) > -> Both services build with the **repo root** as their Root Directory: the pnpm -> workspace needs the root `pnpm-lock.yaml`, and both Dockerfiles now also +> The services build with the **repo root** as their Root Directory: the pnpm +> workspace needs the root `pnpm-lock.yaml`, and the TypeScript Dockerfiles now also > `COPY packages packages`. Do **not** set Root Directory to the app folder. > > | Service | Dockerfile path | @@ -38,6 +38,7 @@ migration runbook were both proven in real releases on 2026-08-02. > | backend | `apps/api/Dockerfile` | > | frontend (web) | `apps/frontend/Dockerfile.web` | > | auth | `apps/auth/Dockerfile` | +> | whatsapp | `legacy/whatsapp/Dockerfile` | > > **Watch paths** — set these per service, or Railway rebuilds *both* on every push > (any commit touches the root lockfile): @@ -46,17 +47,33 @@ migration runbook were both proven in real releases on 2026-08-02. > backend: apps/api/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml > frontend: apps/frontend/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml > auth: apps/auth/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml +> whatsapp: legacy/whatsapp/** > ``` > -> `packages/**` is **not optional**. The shared config packages are copied into both +> For backend, frontend and auth, `packages/**` is **not optional**. The shared config packages are copied into those > images, so a packages-only change alters the built artifact. Omit the pattern and > that change ships nothing — no rebuild, no deploy, and no error. The image simply -> drifts from the repo. +> drifts from the repo. **whatsapp is the exception:** it is a Python service outside the pnpm workspace, so its image contains nothing from +> `packages/`, the root lockfile or `package.json`; watching them would only rebuild it for changes it does not ship. > > **Deploy settings** — backend healthcheck `/api/health` (timeout 300s), frontend -> healthcheck `/`, auth healthcheck `/health` (200 only when the auth database +> healthcheck `/`, whatsapp healthcheck `/`, auth healthcheck `/health` (200 only when the auth database > answers; timeout 300s); restart ON_FAILURE, max 3 retries. > +> +> **whatsapp service (spec 321, interim Python import; lives in `legacy/` because `apps/*` is reserved for the +> TypeScript port)** — one listener on `PORT` (8001 by default; the public domain's target port must match). +> Build: repo-root context, `legacy/whatsapp/Dockerfile`. Health check `/`. The Meta callback URL is +> `https:///whatsapp/v2`, one Meta app per environment. Environment surface (full list in +> `legacy/whatsapp/.env.example`): +> `DEPLOYMENT_TYPE` (required, `staging` or `production`), `BACKEND_SERVER_URL` (apps/api), +> `AUTH_SERVER_URL` and `AUTH_WHATSAPP_API_KEY` (apps/auth, issue #329), +> `META_BUSINESS_PHONE_NUMBER_ID`, `META_ACCESS_TOKEN_FROM_SYS_USER`, `META_ANSARI_APP_SECRET` and +> `META_WEBHOOK_VERIFY_TOKEN` (all four of the same Meta app, all required), +> `SENTRY_DSN`, `WA_MAINTENANCE_MODE`, `WHATSAPP_CHAT_RETENTION_HOURS`, `LOGGING_LEVEL` (unset = `INFO`). +> The service refuses to start if a secret above is missing, or if any `MOCK_*` switch is set outside +> `DEPLOYMENT_TYPE=local`. +> > **auth service (spec 300)** — one listener: the public domain > (`auth.askansari.ai`, `auth-staging.askansari.ai`) targets `PORT` (3100); set it as > a service variable. `backend` reaches it at that domain too (`AUTH_URL`), like any @@ -283,7 +300,7 @@ Order proven for migration `0003` on 2026-08-02: steps: `grant-admin.ts ` shows the account (id, email, created_at, email_verified, role) and grants nothing; `grant-admin.ts --confirm ` grants exactly that account. It never creates an account or touches a - password, and it refuses the system domain. A new admin signs up first; a + password, and it never grants to a system- or noreply-domain address (`--revoke` demotes any account). A new admin signs up first; a warning on a recent or unverified account means: confirm the person out of band before `--confirm`. There is no `ADMIN_EMAILS` on `apps/auth` (spec H-7): the role in the auth database is the only admin truth. diff --git a/apps/api/lib/sentry-scrub.ts b/apps/api/lib/sentry-scrub.ts new file mode 100644 index 00000000..32d547fb --- /dev/null +++ b/apps/api/lib/sentry-scrub.ts @@ -0,0 +1,89 @@ +import type { Event, NodeOptions } from "@sentry/nextjs"; + +// The client IP travels in x-real-ip (Railway's edge) and x-forwarded-for; +// `sendDefaultPii: false` means Sentry should not receive it. The SDK drops +// these itself only while `userInfo` is off, which Sentry v11 no longer defaults. +const DROPPED_HEADERS = new Set(["authorization", "cookie", "x-real-ip", "x-forwarded-for"]); +// Span attributes that carry the same data. @sentry/nextjs copies headers onto +// the root span (`http.request.header.cookie.`, dashes as underscores), +// masking only sensitive-looking names; Next sets `http.target` to the request +// URL with its query; an outgoing fetch's span carries its query string (the +// search tools put the user's question there); requestdata writes the URL, +// query and body as attributes when spans stream; the client IP has an +// attribute of its own. +const DROPPED_HEADER_ATTRIBUTE = + /^http\.(request\.header\.(authorization|cookie|x_real_ip|x_forwarded_for)|response\.header\.set_cookie)(\.|$)/; +const DROPPED_ATTRIBUTES = new Set([ + "url.full", + "url.query", + "http.query", + "http.url", + "http.target", + "http.request.body.data", + "http.client_ip", + "client.address", +]); + +function dropAttributes(data: Record | undefined): void { + if (!data) return; + for (const key of Object.keys(data)) { + if (DROPPED_ATTRIBUTES.has(key) || DROPPED_HEADER_ATTRIBUTE.test(key)) delete data[key]; + } +} + +/** + * Sentry's request-data integration attaches the in-flight request to every + * event raised while it is handled. Strip what can carry user content or a + * credential: the body (user messages), the query string (`/v2/mcp-complete?q=` + * is the user's question), the Authorization header (the user's JWT), + * cookies and the client IP (its headers, its attribute and + * `user.ip_address`), from the request and from span attributes (issue #333). + * Node lowercases incoming header names, but a header object built elsewhere + * may not, so names are matched case-blind. + */ +export function scrubRequestEvent(event: E): E { + const request = event.request; + if (request) { + delete request.data; + delete request.cookies; + delete request.query_string; + if (request.url) request.url = request.url.split("?")[0]; + if (request.headers) { + for (const name of Object.keys(request.headers)) { + if (DROPPED_HEADERS.has(name.toLowerCase())) delete request.headers[name]; + } + } + } + // captureRequestError records the request path with its query. + const nextjs = event.contexts?.nextjs; + if (typeof nextjs?.request_path === "string") nextjs.request_path = nextjs.request_path.split("?")[0]; + if (event.user) delete event.user.ip_address; + dropAttributes(event.contexts?.trace?.data); + for (const span of event.spans ?? []) dropAttributes(span.data); + return event; +} + +/** The same attribute scrub for each span sent on its own. */ +export function scrubSpan }>(span: S): S { + dropAttributes(span.data); + return span; +} + +/** + * The server's request-privacy options; tests spread exactly these. Transactions + * and spans carry the request too. Breadcrumbs are off: an HTTP breadcrumb + * records an outgoing request's query string, and the search tools put the + * user's question there. `beforeSendSpan` covers the static span lifecycle + * only; under `traceLifecycle: 'stream'` it needs `withStreamedSpan` and the + * streamed `attributes` format (a test pins the lifecycle). Never set + * `dataCollection` partially here: any value switches every category it omits + * to collect-all (user info, bodies, GenAI inputs and outputs, database query + * data). + */ +export const SENTRY_REQUEST_PRIVACY = { + maxBreadcrumbs: 0, + beforeBreadcrumb: () => null, + beforeSend: scrubRequestEvent, + beforeSendTransaction: scrubRequestEvent, + beforeSendSpan: scrubSpan, +} satisfies NodeOptions; diff --git a/apps/api/sentry.server.config.ts b/apps/api/sentry.server.config.ts index 7b36c5bd..87e493ac 100644 --- a/apps/api/sentry.server.config.ts +++ b/apps/api/sentry.server.config.ts @@ -1,16 +1,11 @@ import * as Sentry from "@sentry/nextjs"; +import { SENTRY_REQUEST_PRIVACY } from "./lib/sentry-scrub"; + Sentry.init({ dsn: process.env.SENTRY_DSN, enabled: process.env.NODE_ENV !== "test" && !process.env.VITEST, environment: process.env.RAILWAY_ENVIRONMENT || process.env.NODE_ENV, tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0, - - beforeSend(event) { - // Strip request body data to prevent user messages from leaking to Sentry - if (event.request) { - delete event.request.data; - } - return event; - }, + ...SENTRY_REQUEST_PRIVACY, }); diff --git a/apps/api/tests/auth/resolve-user.test.ts b/apps/api/tests/auth/resolve-user.test.ts index c98f86ae..d9661b9a 100644 --- a/apps/api/tests/auth/resolve-user.test.ts +++ b/apps/api/tests/auth/resolve-user.test.ts @@ -243,6 +243,22 @@ describe('scenario 9f: an email the app database already holds', () => { } }); + // Spec 329: the first-use refusal covers the system domain only. A WhatsApp + // user's profile carries apps/auth's other reserved domain, so a tightening + // that refused every reserved address would break WhatsApp here first. + it('a whatsapp-@noreply.askansari.ai profile is accepted, and its row created', async () => { + const { id, jwt } = await signUpDirect(nextEmail('whatsapp')); + const email = `whatsapp-${id}@noreply.askansari.ai`; + await service.client.query( + "update auth.users set email = $1, registered_via = 'whatsapp' where id = $2", + [email, id] + ); + + expect((await resolveUser(id, jwt)).kind).toBe('user'); + expect(await appUser(id)).toMatchObject({ id, email, registered_via: 'whatsapp' }); + expect(h.sentry).toEqual([]); + }); + it('the email is compared normalized', async () => { const email = nextEmail('case'); const legacy = await insertLegacyUserRow(app, email); diff --git a/apps/api/tests/contract/whatsapp-exchange.test.ts b/apps/api/tests/contract/whatsapp-exchange.test.ts new file mode 100644 index 00000000..7de355d5 --- /dev/null +++ b/apps/api/tests/contract/whatsapp-exchange.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomInt } from 'node:crypto'; +import { NextRequest } from 'next/server'; +import type { PGlite } from '@electric-sql/pglite'; + +/** + * A token from apps/auth's WhatsApp exchange (spec 329) works in apps/api + * unchanged: the real apps/auth over its own pglite mints it for a BSUID, and + * a real apps/api route accepts it through `requireUser` (JWKS verification, + * first-use `GET /api/account/me` with that token, a `public.users` row under + * the same UUID). apps/api has no WhatsApp code (source-scan scan 5); this is + * the proof it needs none. Only the app `db` handle and Sentry are mocked. + */ + +const h = vi.hoisted(() => ({ db: null as unknown })); + +vi.mock('@/lib/db/index', () => ({ + get db() { + return h.db; + }, + closeDb: async () => {}, +})); + +vi.mock('@sentry/nextjs', () => ({ captureMessage: () => {}, setUser: () => {} })); + +import { GET as threadsGet } from '../../src/app/api/v2/threads/route'; +import { serviceUsers } from '../../../../packages/auth/src'; +import { appDrizzle, migrateAppDb, startAuth, type AuthService } from '../auth/helpers'; + +const EXCHANGE_PATH = '/api/whatsapp/session'; + +const digits = (n: number) => Array.from({ length: n }, () => randomInt(10)).join(''); + +let service: AuthService; +let app: PGlite; +let key: string; + +beforeAll(async () => { + service = await startAuth(); + app = await migrateAppDb(); + h.db = appDrizzle(app); + const serviceUser = await serviceUsers.createServiceUser(service.db, 'whatsapp'); + // A `whatsapp: mint` key for the WhatsApp service user, with the script's prefix and + // permission but no rate limit (so `requestCount` never moves) and no budget: this test + // proves the token path, not the key's limits. + ({ key } = await service.auth.api.createApiKey({ + body: { + userId: serviceUser.id, + prefix: 'ansari_wa_', + name: 'contract', + permissions: { whatsapp: ['mint'] }, + rateLimitEnabled: false, + }, + })); +}); + +afterAll(async () => { + await service.close(); + await app.close(); +}); + +/** The exchange, called as the WhatsApp service calls it: bearer key, JSON body. */ +async function exchange(body: Record): Promise { + const res = await fetch(`${service.publicUrl}${EXCHANGE_PATH}`, { + method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { token: string }).token; +} + +async function getThreads(token: string) { + const res = await threadsGet( + new NextRequest('http://localhost/api/v2/threads', { headers: { authorization: `Bearer ${token}` } }) + ); + return { status: res.status, text: await res.text() }; +} + +/** Every row of every table in the app database, as text. */ +async function appDatabaseText(): Promise { + const { rows } = await app.query<{ name: string }>( + "select table_name as name from information_schema.tables where table_schema = 'public'" + ); + const dumps = await Promise.all( + rows.map(async ({ name }) => { + const result = await app.query<{ row: string }>(`select row_to_json(t)::text as row from "${name}" t`); + return result.rows.map((r) => r.row).join('\n'); + }) + ); + return dumps.join('\n'); +} + +/** Every row of every table in the auth database, as text (spec 329 §5: no phone number stored). */ +async function authDatabaseText(): Promise { + const { rows } = await service.client.query<{ name: string }>( + "select table_name as name from information_schema.tables where table_schema = 'auth'" + ); + const dumps = await Promise.all( + rows.map(async ({ name }) => { + const result = await service.client.query<{ row: string }>(`select row_to_json(t)::text as row from auth."${name}" t`); + return result.rows.map((r) => r.row).join('\n'); + }) + ); + return dumps.join('\n'); +} + +describe('a WhatsApp exchange token through apps/api requireUser', () => { + it('passes, creates the app row on first use under the same UUID, and carries no phone number', async () => { + const bsuid = `US.${digits(20)}`; + const phone = `1${digits(11)}`; + const token = await exchange({ bsuid, phone_number: phone }); + const [, payload] = token.split('.'); + const { sub } = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { sub: string }; + + const first = await getThreads(token); + expect(first.status).toBe(200); + + const { rows } = await app.query>( + 'select id, email, registered_via from users where id = $1', + [sub] + ); + expect(rows).toEqual([{ id: sub, email: `whatsapp-${sub}@noreply.askansari.ai`, registered_via: 'whatsapp' }]); + + // A second message: a fresh exchange for the same BSUID, the same user and row. + const again = await getThreads(await exchange({ bsuid })); + expect(again.status).toBe(200); + const { rows: count } = await app.query<{ n: number }>('select count(*)::int as n from users'); + expect(count[0].n).toBe(1); + + const appText = await appDatabaseText(); + expect(appText).toContain(sub); + // The phone number is accepted and ignored at apps/auth too: in no auth row (spec 329 §5). + const authText = await authDatabaseText(); + expect(authText).toContain(bsuid); + expect(authText).not.toContain(phone); + for (const sensitive of [phone, bsuid]) { + expect(appText).not.toContain(sensitive); + expect(first.text).not.toContain(sensitive); + expect(again.text).not.toContain(sensitive); + } + }); +}); diff --git a/apps/api/tests/contributor-docs.test.ts b/apps/api/tests/contributor-docs.test.ts index 8c7840e0..5e8f84bb 100644 --- a/apps/api/tests/contributor-docs.test.ts +++ b/apps/api/tests/contributor-docs.test.ts @@ -129,9 +129,9 @@ describe('CONTRIBUTING.md', () => { it('names required CI checks that the workflow actually emits', () => { const ci = parse(readFileSync(resolve(repoRoot, '.github/workflows/ci.yml'), 'utf8')); const emitted = Object.values(ci.jobs as Record).map((j) => j.name); - // All four jobs are required on `develop`, so the doc must name each one + // All five jobs are required on `develop`, so the doc must name each one // exactly as the workflow emits it — and notice when a job is added or renamed. - expect(emitted.length).toBe(4); + expect(emitted.length).toBe(5); for (const check of emitted) { expect(doc).toContain(`\`${check}\``); } diff --git a/apps/api/tests/cutover-docs.test.ts b/apps/api/tests/cutover-docs.test.ts index f41cfa63..ee90ab80 100644 --- a/apps/api/tests/cutover-docs.test.ts +++ b/apps/api/tests/cutover-docs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { limits, serviceUsers } from '../../../packages/auth/src'; // The Better Auth cutover runbooks (spec 300, plan phase 12) are executed // mid-window by an operator who cannot stop to hunt for a file. Every repo @@ -84,7 +85,8 @@ describe('the window runbook', () => { }); it('applies every migration with both psql flags, naming the database', () => { - const applies = doc.split('\n').filter((l) => /psql .*-f \S*drizzle\/\S+\.sql/.test(l)); + // The app window migrations by file; the auth journal by its loop, one file at a time (spec 329). + const applies = doc.split('\n').filter((l) => /psql .*-f \S*drizzle\/\S+\.sql/.test(l) || /auth-journal\.mjs files/.test(l)); expect(applies.length).toBe(4); for (const line of applies) { expect(line).toMatch(/psql "\$(APP|AUTH)_DATABASE_URL" -X -v ON_ERROR_STOP=1 --single-transaction -f /); @@ -367,6 +369,30 @@ describe('the deployment guide', () => { expect(commands.length).toBeGreaterThan(25); }); + // Main architect, 2026-10-28: both pages apply the WHOLE auth journal in _journal.json order, one + // single-transaction psql per file, before the copy, and check it per migration from the journal. + it('applies the whole auth journal before the copy, checked from the journal, never a hardcoded table count', () => { + const apply = + 'for f in $(node apps/auth/scripts/cutover/auth-journal.mjs files); do psql "$AUTH_DATABASE_URL" -X -v ON_ERROR_STOP=1 --single-transaction -f "$f" || break; done'; + const check = 'node apps/auth/scripts/cutover/auth-journal.mjs check | psql "$AUTH_DATABASE_URL" -X -q -At -v ON_ERROR_STOP=1'; + const step5 = runbook.slice(runbook.indexOf('## 5. The auth baseline'), runbook.indexOf('## 6. ')); + const step3 = guide.slice(guide.indexOf('## 3. Auth schema'), guide.indexOf('## 4. ')); + for (const [name, text] of [['runbook step 5', step5], ['guide step 3', step3]] as const) { + expect(text, name).toContain(apply); + expect(text, name).toContain(check); + // Interactive shells have no pipefail: without it a failed `check` gives psql empty input, exit 0. + expect(text, name).toContain(`set -o pipefail\n${check}`); + expect(text, name).not.toMatch(/-f packages\/auth\/drizzle\/\d{4}_/); + expect(text, name).not.toMatch(/table_schema = 'auth'/); + } + expect(step5).toMatch(/one `\|t` line per file `files` listed, in the same order/); + expect(step5).toContain('**No output at all is a\nfailure**: stop.'); + expect(step3).toContain('no output at all is a failure'); + expect(step5).toContain('Record one `auth-journal-applied: ` line per tag it printed'); + expect(runbook.indexOf(apply)).toBeLessThan(runbook.indexOf('## 6. The copy and the audits')); + expect(guide.indexOf(apply)).toBeLessThan(guide.indexOf('## 4. Copy the users')); + }); + it.each(commands)('runs the runbook command: %s', (line) => { const literal = line.replace(/\$API\b/g, 'https://api.askansari.ai').replace(/\$AUTH\b/g, 'https://auth.askansari.ai'); // The runbook writes some auth-side checks as "the same for `$AUTH_DATABASE_URL`". @@ -376,7 +402,7 @@ describe('the deployment guide', () => { it('follows the runbook order: baseline, copy, app migrations, admins, domain, merge', () => { const at = (s: string) => guide.indexOf(s); - const order = ['0000_auth_schema.sql', 'cutover/copy.sh', '0011_better_auth_window.sql', 'grant-admin.ts', '/health', 'git push origin', 'MAINTENANCE_MODE=false` on `backend`. Then']; + const order = ['auth-journal.mjs files', 'cutover/copy.sh', '0011_better_auth_window.sql', 'grant-admin.ts', '/health', 'git push origin', 'MAINTENANCE_MODE=false` on `backend`. Then']; const positions = order.map(at); expect(positions.every((p) => p > 0)).toBe(true); expect([...positions].sort((a, b) => a - b)).toEqual(positions); @@ -415,3 +441,111 @@ describe('the deployment guide names every variable the new code requires', () = for (const name of authVars) expect(auth, name).toContain(`| \`${name}\` |`); }); }); + +describe('the service-keys runbook (spec 329)', () => { + const KEYS_RUNBOOK = 'docs/cutover/service-keys-runbook.md'; + const doc = read(KEYS_RUNBOOK); + const section = (heading: string) => { + const start = doc.indexOf(`\n## ${heading}`); + if (start < 0) throw new Error(`no runbook section "${heading}"`); + const end = doc.indexOf('\n## ', start + 1); + return doc.slice(start, end < 0 ? undefined : end); + }; + + it('every repo path it names exists', () => { + const paths = namedRepoPaths(doc); + expect(paths.length).toBeGreaterThanOrEqual(4); + for (const path of paths) { + expect(existsSync(resolve(repoRoot, path)), `${KEYS_RUNBOOK} names ${path}, which does not exist`).toBe(true); + } + }); + + it('carries no bare apply record, which the auth journal parity test would read as one', () => { + const record = /^auth-journal-applied:/m; + expect(record.test('auth-journal-applied: 0001_api_key')).toBe(true); + expect(record.test('record `auth-journal-applied: ` in the notes')).toBe(false); + expect(doc).not.toMatch(record); + }); + + it('documents every subcommand of the script', () => { + const script = read('apps/auth/scripts/service-keys.ts'); + const usage = script.slice(script.indexOf('export const USAGE'), script.indexOf('].join')); + const commands = [...usage.matchAll(/service-keys\.ts ([a-z-]+) /g)].map((m) => m[1] as string); + expect(commands).toEqual(['create-service', 'create-key', 'list', 'rotate', 'update-limits', 'revoke']); + for (const command of commands) expect(doc, command).toMatch(new RegExp(`^\\| \`${command} `, 'm')); + }); + + it("issues with the WhatsApp key's initial limits from limits.ts", () => { + const initial = limits.WHATSAPP_KEY_INITIAL; + expect(section('Issuance')).toContain( + `--rate-max ${initial.rateLimitMax} --rate-window ${initial.rateLimitTimeWindow / 1000} --daily ${initial.refillAmount}` + ); + }); + + it('covers the handoff, the rotation overlap and its doubled ceiling, revocation and pruning', () => { + expect(section('The handoff')).toMatch(/\*\*straight into the consumer's Railway variable\*\*/); + expect(section('Rotation')).toMatch(/\*\*While two keys are live, the service's ceiling doubles\*\*/); + expect(section('Rotation')).toMatch(/\*\*Every key is rotated every\s+6 months\*\*, owned by the operator/); + expect(section('Revocation and pruning')).toContain('`enabled = false`'); + expect(section('Revocation and pruning')).toMatch(/Pruning: none expected/); + expect(section('Limits')).toMatch(/\*\*judgement, not measurement\*\*/); + expect(section('Limits')).toContain('`update-limits`'); + }); + + it("gives the script's accepted ranges as WHATSAPP_KEY_BOUNDS defines them", () => { + const { rateLimitMax, rateLimitTimeWindow, refillAmount } = limits.WHATSAPP_KEY_BOUNDS; + const n = (v: number) => v.toLocaleString('en-US'); + const rows = [...section('Limits').matchAll(/^\| `(--[a-z-]+)` \| ([^|]+) \|$/gm)].map((m) => [m[1], m[2]]); + expect(rows).toEqual([ + ['--rate-max', `${n(rateLimitMax.min)} to ${n(rateLimitMax.max)}`], + [ + '--rate-window', + `1 second to 24 hours (${n(rateLimitTimeWindow.min / 1000)} to ${n(rateLimitTimeWindow.max / 1000)} seconds)`, + ], + ['--daily', `${n(refillAmount.min)} to ${n(refillAmount.max)}`], + ]); + expect(rateLimitTimeWindow.min).toBe(1000); + expect(rateLimitTimeWindow.max).toBe(24 * 60 * 60 * 1000); + }); + + it('gives the staging apply before the merge (duplicate check, lock, verification) and leaves production to the cutover runbook', () => { + const apply = section('Applying `0001`: staging, before the merge'); + expect(apply).toMatch(/group by provider_id, account_id\s+having count\(\*\) > 1;/); + expect(apply).toContain('**ACCESS EXCLUSIVE lock on `auth.account`**'); + expect(apply).toContain('psql -X -v ON_ERROR_STOP=1 --single-transaction'); + expect(apply).toContain('**on staging the apply precedes the merge**'); + expect(apply).toMatch(/> Apply the whole auth journal in `_journal.json` order, one `psql -X -v ON_ERROR_STOP=1\s+> --single-transaction` per file\. The duplicate pre-check is needed only where data already\s+> exists \(staging now\)\./); + expect(apply).toContain('[`better-auth-cutover.md`](better-auth-cutover.md)'); + expect(apply).not.toMatch(/rebase/i); + }); + + it('explains every exchange alert kind', () => { + const alerts = read('apps/auth/src/alerts.ts'); + const union = alerts.slice(alerts.indexOf('export type ExchangeAlert ='), alerts.indexOf('export type KeyRefusalCode')); + const kinds = [...union.matchAll(/kind: '([a-z-]+)'/g)].map((m) => m[1] as string); + expect(kinds.length).toBe(6); + for (const kind of kinds) expect(section('Alerts'), kind).toMatch(new RegExp(`^\\| \`${kind}\``, 'm')); + }); + + it('the eligibility repair starts from the exact log line and names every reason', () => { + const alerts = read('apps/auth/src/alerts.ts'); + const message = /INELIGIBLE_USER_MESSAGE = '([^']+)'/.exec(alerts)?.[1]; + expect(message).toBeDefined(); + const repair = section('Eligibility repair'); + expect(repair).toContain(`\`${message}\``); + for (const reason of ['accounts', 'address', 'registered_via', 'role']) expect(repair).toContain(`\`${reason}\``); + }); + + it('has the key log the script banner points to', () => { + expect(read('apps/auth/scripts/service-keys.ts')).toContain("the runbook's key log"); + expect(section('Key log')).toMatch(/^\| Date \(UTC\) \| Action \| Service \| User id \| Key id \|/m); + }); + + it('gives exactly the secret-scanning patterns the code defines, and where the owner registers them', () => { + const scanning = section('Secret scanning'); + const rows = [...scanning.matchAll(/^\| `([a-z-]+)` \| `([^`]+)` \|$/gm)].map((m) => [m[1], m[2]]); + expect(rows).toEqual(Object.entries(serviceUsers.SECRET_SCANNING_PATTERNS).map(([name, re]) => [name, re.source])); + expect(scanning).toContain('Settings → Code security → Secret scanning → Custom patterns'); + expect(scanning).toMatch(/\*\*revoke the key at once\*\*/); + }); +}); diff --git a/apps/api/tests/root-test-script.test.ts b/apps/api/tests/root-test-script.test.ts new file mode 100644 index 00000000..a677648b --- /dev/null +++ b/apps/api/tests/root-test-script.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +// Pin for issue #330: the root `pnpm test` runs every package's vitest suite at +// once under turbo, and each suite defaults to (cores - 1) workers, so a 14-core +// machine ran ~50 forks and pglite setup hooks crossed the 30 s deadline under +// ordinary load. The root script caps each suite's pool instead of raising +// deadlines. This only pins that the cap is there; the real proof is the A/B in +// codev/reviews/330: two root runs started together went red in 5/5 pairs +// without the cap and green in 5/5 with it. The root package.json is in this +// task's turbo inputs, so removing the flag busts the cache and this file runs. + +const rootPackageJson = resolve(__dirname, '../../../package.json'); + +describe('root test script', () => { + // Exact, not a pattern: 4 is the measured value, and a looser match would + // pass an ineffective cap (`=64`) or a second flag overriding it. + it('caps vitest workers at the measured 4', () => { + const { scripts } = JSON.parse(readFileSync(rootPackageJson, 'utf8')) as { + scripts: Record; + }; + + expect(scripts.test).toBe('turbo run test --filter=!ansari-mcp -- --maxWorkers=4'); + }); +}); diff --git a/apps/api/tests/self-hosting-docs.test.ts b/apps/api/tests/self-hosting-docs.test.ts index 3ea7477e..460dba7e 100644 --- a/apps/api/tests/self-hosting-docs.test.ts +++ b/apps/api/tests/self-hosting-docs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { limits } from '../../../packages/auth/src'; /** * docs/self-hosting.md against the code (issue #21; spec 300 amendment H-7). @@ -46,3 +47,40 @@ describe('self-hosting docs: two databases, two services (spec 300)', () => { expect(doc).not.toMatch(/applied with `pnpm db:migrate` \(from `apps\/api\/`\) against\s+the production/); }); }); + +describe('self-hosting docs: service keys and the WhatsApp exchange (spec 329)', () => { + const section = doc.slice(doc.indexOf('### Service keys and the WhatsApp exchange')); + + it('names the route, the script and the runbook, and each exists', () => { + expect(section).toContain('`POST /api/whatsapp/session`'); + expect(readFileSync(resolve(__dirname, '../../auth/src/alerts.ts'), 'utf8')).toContain( + "WHATSAPP_SESSION_PATH = '/api/whatsapp/session'" + ); + expect(section).toContain('`apps/auth/scripts/service-keys.ts`'); + expect(existsSync(resolve(__dirname, '../../auth/scripts/service-keys.ts'))).toBe(true); + expect(section).toContain('`docs/cutover/service-keys-runbook.md`'); + expect(existsSync(resolve(__dirname, '../../../docs/cutover/service-keys-runbook.md'))).toBe(true); + }); + + it("states limits.ts's values as they are", () => { + const initial = limits.WHATSAPP_KEY_INITIAL; + expect(initial.rateLimitTimeWindow).toBe(60_000); + expect(section).toContain(`(\`WHATSAPP_KEY_INITIAL\`: ${initial.rateLimitMax} per minute, 3,000 a day)`); + expect(initial.refillAmount).toBe(3000); + const perUser = limits.WHATSAPP_EXCHANGE_PER_USER; + expect(section).toContain(`(\`WHATSAPP_EXCHANGE_PER_USER\`: ${perUser.max} per\n${perUser.window / 60} minutes)`); + const { rateLimitMax, rateLimitTimeWindow, refillAmount } = limits.WHATSAPP_KEY_BOUNDS; + expect([rateLimitTimeWindow.min, rateLimitTimeWindow.max]).toEqual([1000, 24 * 60 * 60 * 1000]); + const n = (v: number) => v.toLocaleString('en-US'); + expect(section).toContain( + `(\`WHATSAPP_KEY_BOUNDS\`, inclusive: \`--rate-max\` ${n(rateLimitMax.min)} to ${n(rateLimitMax.max)}, \`--rate-window\` 1 second to\n24 hours, \`--daily\` ${n(refillAmount.min)} to ${n(refillAmount.max)})` + ); + }); + + it('puts migration 0001 before the code, which refuses to boot without it', () => { + expect(section).toMatch(/\*\*Migration before code\.\*\* `packages\/auth\/drizzle\/0001_api_key\.sql`/); + expect(existsSync(resolve(__dirname, '../../../packages/auth/drizzle/0001_api_key.sql'))).toBe(true); + expect(section).toMatch(/refuses to boot\s+without it/); + expect(doc).toMatch(/refuses to start when the auth database lacks `auth\.apikey`/); + }); +}); diff --git a/apps/api/tests/sentry/request-envelopes.ts b/apps/api/tests/sentry/request-envelopes.ts new file mode 100644 index 00000000..c6aae3af --- /dev/null +++ b/apps/api/tests/sentry/request-envelopes.ts @@ -0,0 +1,121 @@ +// Shared driver for the issue #333 Sentry tests. Production-shaped: default +// integrations on, only the transport replaced, sampled as +// sentry.server.config.ts samples outside production. @sentry/nextjs honours +// only the first init in a process, so each file inits once, with its own +// options: the scrubbed run and its negative control live in separate files. +// +// Next reports every unhandled route error through `onRequestError`, which +// apps/api wires to Sentry.captureRequestError (src/instrumentation.ts); it +// attaches every request header. Events and transactions raised in a request +// scope carry the request data the scope holds, body and query included, and +// the root span's attributes: Next sets `http.target` to the request URL, +// query included (next/dist/server/base-server.js). @sentry/nextjs turns off +// Sentry's own incoming-request spans, so the root span here is a hand-made +// one carrying those attributes. A search tool's outgoing fetch puts the +// user's search terms in its query string, which an HTTP breadcrumb records. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import * as Sentry from "@sentry/nextjs"; + +import { onRequestError } from "@/instrumentation"; + +export const FIXTURES = { + bearer: "USERJWTMARKER", + cookie: "COOKIEVALUEMARKER", + body: "USERMESSAGEMARKER", + query: "USERQUESTIONMARKER", + search: "SEARCHTERMMARKER", + ip: "203.0.113.77", +}; + +const QUESTION_PATH = `/api/v2/mcp-complete?q=${FIXTURES.query}`; + +const HEADERS = { + host: "api.example.test", + authorization: `Bearer ${FIXTURES.bearer}`, + cookie: `probe=${FIXTURES.cookie}`, + "x-real-ip": FIXTURES.ip, + "x-forwarded-for": FIXTURES.ip, + "content-type": "application/json", +}; + +type Item = [{ type: string }, { request?: Record }]; + +const envelopes: unknown[] = []; + +export function initSentry(options: Sentry.NodeOptions): void { + Sentry.init({ + dsn: "https://public@o0.ingest.sentry.io/0", + environment: "test", + tracesSampleRate: 1.0, + ...options, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => true, + }), + }); +} + +/** + * Every envelope item an unhandled route error and an in-request event + * produce, and which fixtures they hold (by name). The in-request event is + * raised inside the root span after a search tool's outgoing fetch. + */ +export async function captureRequestEnvelopes() { + envelopes.length = 0; + const search = createServer((_req, res) => res.end("[]")); + await new Promise((r) => search.listen(0, "127.0.0.1", r)); + const searchUrl = `http://127.0.0.1:${(search.address() as AddressInfo).port}/search`; + try { + await onRequestError( + new Error("probe: unhandled in a route"), + { path: QUESTION_PATH, method: "GET", headers: HEADERS }, + { routerKind: "App Router", routePath: "/api/v2/mcp-complete", routeType: "route" } + ); + await Sentry.withIsolationScope(async (scope) => { + scope.setSDKProcessingMetadata({ + normalizedRequest: { + method: "GET", + url: `https://api.example.test${QUESTION_PATH}`, + query_string: `q=${FIXTURES.query}`, + headers: HEADERS, + data: JSON.stringify({ message: FIXTURES.body }), + }, + }); + await Sentry.startSpan( + { + name: "GET /api/v2/mcp-complete", + forceTransaction: true, + attributes: { + "http.target": QUESTION_PATH, + "http.url": `https://api.example.test${QUESTION_PATH}`, + "url.full": `https://api.example.test${QUESTION_PATH}`, + "url.query": `q=${FIXTURES.query}`, + "http.client_ip": FIXTURES.ip, + "client.address": FIXTURES.ip, + "http.request.body.data": JSON.stringify({ message: FIXTURES.body }), + }, + }, + async () => { + await (await fetch(`${searchUrl}?q=${FIXTURES.search}`)).text(); + Sentry.captureException(new Error("probe: raised inside a request")); + } + ); + }); + await Sentry.flush(2000); + } finally { + await new Promise((r) => search.close(r)); + } + const items = envelopes + .flatMap((e) => (e as [unknown, Item[]])[1]) + .filter(([header]) => header.type === "event" || header.type === "transaction"); + const text = JSON.stringify(envelopes); + const leaked = Object.entries(FIXTURES) + .filter(([, value]) => text.includes(value)) + .map(([name]) => name); + return { items, leaked }; +} diff --git a/apps/api/tests/sentry/sentry-request-data-collect-all.test.ts b/apps/api/tests/sentry/sentry-request-data-collect-all.test.ts new file mode 100644 index 00000000..66a334ff --- /dev/null +++ b/apps/api/tests/sentry/sentry-request-data-collect-all.test.ts @@ -0,0 +1,21 @@ +// The scrub as the last line of defence (issue #333): with collection widened +// to collect-all, as Sentry v11 collects by default, the SDK keeps the client +// IP headers, cookies and bodies, and SENTRY_REQUEST_PRIVACY alone must keep +// every fixture out. Its own file: @sentry/nextjs honours only the first init +// in a process. +import * as Sentry from "@sentry/nextjs"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { SENTRY_REQUEST_PRIVACY } from "@/lib/sentry-scrub"; + +import { captureRequestEnvelopes, initSentry } from "./request-envelopes"; + +// Any dataCollection value switches every omitted category to collect-all. +beforeAll(() => initSentry({ ...SENTRY_REQUEST_PRIVACY, dataCollection: { userInfo: true } })); +afterAll(() => Sentry.close()); + +it("under collect-all settings, the scrub still keeps every fixture out", async () => { + expect(Sentry.getClient()?.getDataCollectionOptions()).toMatchObject({ userInfo: true, cookies: true }); + const { leaked } = await captureRequestEnvelopes(); + expect(leaked).toEqual([]); +}); diff --git a/apps/api/tests/sentry/sentry-request-data-control.test.ts b/apps/api/tests/sentry/sentry-request-data-control.test.ts new file mode 100644 index 00000000..a6d85cbd --- /dev/null +++ b/apps/api/tests/sentry/sentry-request-data-control.test.ts @@ -0,0 +1,18 @@ +// Negative control for sentry-request-data.test.ts (issue #333): the same +// envelopes, without SENTRY_REQUEST_PRIVACY, carry every fixture. Its own file: +// @sentry/nextjs honours only the first init in a process. +import * as Sentry from "@sentry/nextjs"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { captureRequestEnvelopes, FIXTURES, initSentry } from "./request-envelopes"; + +beforeAll(() => initSentry({})); +afterAll(() => Sentry.close()); + +it("without the scrub, the same envelopes carry every fixture", async () => { + // The SDK drops the client-IP headers itself while `userInfo` is off, but + // the IP still leaks through its span attribute; the search terms through + // the outgoing fetch's breadcrumb. + const { leaked } = await captureRequestEnvelopes(); + expect(leaked.sort()).toEqual(Object.keys(FIXTURES).sort()); +}); diff --git a/apps/api/tests/sentry/sentry-request-data.test.ts b/apps/api/tests/sentry/sentry-request-data.test.ts new file mode 100644 index 00000000..949ff7ec --- /dev/null +++ b/apps/api/tests/sentry/sentry-request-data.test.ts @@ -0,0 +1,39 @@ +// Issue #333: an event raised inside a request carries that request's headers +// (the user's JWT in Authorization, cookies) and, when an integration read it, +// its body. The negative control is sentry-request-data-control.test.ts. +// +// Assertions name which fixture leaked, never its value. +import * as Sentry from "@sentry/nextjs"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { SENTRY_REQUEST_PRIVACY } from "@/lib/sentry-scrub"; + +import { captureRequestEnvelopes, initSentry } from "./request-envelopes"; + +beforeAll(() => initSentry(SENTRY_REQUEST_PRIVACY)); +afterAll(() => Sentry.close()); + +it("an event raised inside a request with a bearer carries no Authorization header, cookie, client IP, body, query or search terms", async () => { + const { items, leaked } = await captureRequestEnvelopes(); + expect(items.map(([header]) => header.type).sort()).toEqual(["event", "event", "transaction"]); + for (const [, event] of items) { + expect(event.request?.method).toBe("GET"); + expect(event.request?.headers).toMatchObject({ host: "api.example.test" }); + } + expect(leaked).toEqual([]); +}); + +// These pins exist to fail on an upgrade or an option change, not to document +// the defaults: Sentry v11 removes `sendDefaultPii` and collects by default, +// and under `traceLifecycle: 'stream'` spans skip beforeSendTransaction and +// need a streamed beforeSendSpan. +it("leaves Sentry's data collection and span lifecycle at what the scrub was written for", () => { + expect(Sentry.getClient()?.getOptions().traceLifecycle ?? "static").toBe("static"); + const resolved = Sentry.getClient()?.getDataCollectionOptions(); + expect(resolved).toMatchObject({ + userInfo: false, + httpBodies: [], + genAI: { inputs: false, outputs: false }, + databaseQueryData: false, + }); +}); diff --git a/apps/api/tests/source-scan/scan.ts b/apps/api/tests/source-scan/scan.ts index 5bb54f0b..12fbbe16 100644 --- a/apps/api/tests/source-scan/scan.ts +++ b/apps/api/tests/source-scan/scan.ts @@ -144,3 +144,32 @@ export function removedHopHits(source: string): string[] { export function removedHopTextHits(text: string): string[] { return text.split('\n').filter((line) => REMOVED_HOP.some((re) => re.test(line))); } + +// ── Scan 5: apps/api has no WhatsApp-specific code (spec 329) ── + +/** + * apps/api serves a WhatsApp user exactly as a web user: the exchange token + * passes `requireUser` unchanged. The scope is apps/api's runtime source + * (`src/`, `lib/`); its tests are outside it, since the contract test names + * `registered_via = 'whatsapp'`. Comments count: a branch planned in a comment + * is WhatsApp code in waiting. + */ +export const WHATSAPP_SCAN_DIRS = ['apps/api/src/', 'apps/api/lib/'] as const; + +const WHATSAPP = /whatsapp/i; + +export function isInWhatsappScanScope(rel: string): boolean { + const posix = rel.split(path.sep).join('/'); + return WHATSAPP_SCAN_DIRS.some((dir) => posix.startsWith(dir)); +} + +/** Every in-scope line naming WhatsApp (any case), by file; out-of-scope files are skipped. */ +export function whatsappHits(files: readonly { path: string; source: string }[]): Record { + const hits: Record = {}; + for (const file of files) { + if (!isInWhatsappScanScope(file.path)) continue; + const found = file.source.split('\n').filter((line) => WHATSAPP.test(line)); + if (found.length > 0) hits[file.path] = found; + } + return hits; +} diff --git a/apps/api/tests/source-scan/source-scan.test.ts b/apps/api/tests/source-scan/source-scan.test.ts index 8b64a7fc..71e03e5b 100644 --- a/apps/api/tests/source-scan/source-scan.test.ts +++ b/apps/api/tests/source-scan/source-scan.test.ts @@ -16,6 +16,7 @@ import { removedHopHits, removedHopTextHits, sourceFiles, + whatsappHits, } from './scan'; function report(name: string, files: string[], hits: Record): void { @@ -250,3 +251,36 @@ describe('scan 4: the removed internal hop stays gone (owner amendment 2026-10-1 expect(hits).toEqual({}); }); }); + +describe('scan 5: apps/api has no WhatsApp-specific code (spec 329)', () => { + const KNOWN_BAD = "if (registeredVia === 'whatsapp') return;"; + + it('catches the known-bad line in a fixture under the scanned glob', () => { + for (const fixture of ['apps/api/lib/auth/fixture.ts', 'apps/api/src/app/api/v2/fixture/route.ts']) { + expect(whatsappHits([{ path: fixture, source: KNOWN_BAD }]), fixture).toEqual({ [fixture]: [KNOWN_BAD] }); + } + for (const bad of ['// WhatsApp users skip this', "const via = 'WHATSAPP';", "import { x } from './whatsApp';"]) { + expect(whatsappHits([{ path: 'apps/api/lib/x.ts', source: bad }]), bad).not.toEqual({}); + } + }); + + it('ignores the same line outside the glob', () => { + for (const outside of [ + 'apps/api/tests/contract/fixture.test.ts', + 'apps/api/db/schema/fixture.ts', + 'apps/api/library/fixture.ts', + 'apps/auth/src/routes/fixture.ts', + 'apps/whatsapp/lib/fixture.ts', + ]) { + expect(whatsappHits([{ path: outside, source: KNOWN_BAD }]), outside).toEqual({}); + } + }); + + it('apps/api src and lib have no hits', () => { + const files = [...sourceFiles('apps/api/src'), ...sourceFiles('apps/api/lib')]; + expect(files.length).toBeGreaterThan(60); + const hits = whatsappHits(files.map((file) => ({ path: file, source: readSource(file) }))); + report('whatsapp (apps/api src + lib)', files, hits); + expect(hits).toEqual({}); + }); +}); diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 6671c362..b72610cd 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -15,6 +15,10 @@ // apps/auth/scripts (the source scans; the cutover scripts the runbook // names). Without them a doc edit left ansari-api#test at the same hash // (verified) and a warm cache replayed a stale doc test. + // + // The root package.json is here for root-test-script.test.ts (issue #330), + // which pins the root `test` script's worker cap: without it, removing the + // cap left this task at the same hash and a warm cache replayed the pin green. "test": { "inputs": [ "$TURBO_DEFAULT$", @@ -32,8 +36,16 @@ "$TURBO_ROOT$/.github/scripts/**", "$TURBO_ROOT$/.github/workflows/**", "$TURBO_ROOT$/.gitleaksignore", - "$TURBO_ROOT$/.gitleaks.toml" + "$TURBO_ROOT$/.gitleaks.toml", + "$TURBO_ROOT$/package.json" ] + }, + + // CI runs the same pin through test:coverage, so it needs the root + // package.json too. Overriding `inputs` replaces the root task's list, so + // the inherited entries are repeated. + "test:coverage": { + "inputs": ["$TURBO_DEFAULT$", ".env.ci", "$TURBO_ROOT$/package.json"] } } } diff --git a/apps/auth/Dockerfile b/apps/auth/Dockerfile index 9bd80287..17a4c55a 100644 --- a/apps/auth/Dockerfile +++ b/apps/auth/Dockerfile @@ -4,9 +4,10 @@ # # Same shape as apps/api/Dockerfile, on purpose: single stage, the install CI # verifies, real environment variables injected by Railway at RUNTIME and none -# baked in. The full install is kept (not prod-pruned) because the two +# baked in. The full install is kept (not prod-pruned) because the # operator scripts can run in this image, from /repo/apps/auth: -# ./node_modules/.bin/tsx scripts/grant-admin.ts … (and scripts/ban-user.ts) +# ./node_modules/.bin/tsx scripts/grant-admin.ts … (and scripts/ban-user.ts, +# scripts/service-keys.ts) # They need tsx and the raw-TS @ansari/auth workspace link, and need no psql; # they also run from a checkout with the auth DATABASE_URL. The cutover itself # (copy, audits, migrations) runs on the owner's machine over Tailscale, not diff --git a/apps/auth/scripts/cutover/02-import-auth.sql b/apps/auth/scripts/cutover/02-import-auth.sql index 401dc4b7..0b88912a 100644 --- a/apps/auth/scripts/cutover/02-import-auth.sql +++ b/apps/auth/scripts/cutover/02-import-auth.sql @@ -8,9 +8,12 @@ -- copy.sh --dry-run passes `-v dry_run=1`: everything below runs as written, -- guards included, and the transaction ends in ROLLBACK, after printing the -- auth-side audit from inside it. Against an auth database with no schema yet --- (the window before step 5), copy.sh also passes `-v dry_run_baseline=1`, and --- the 0000 baseline is applied inside the same transaction, so the rollback --- takes it away again. +-- (the window before step 5), copy.sh also passes `-v dry_run_baseline=`: +-- the WHOLE auth journal in `_journal.json` order (`auth-journal.mjs sql`, every +-- migration, then an assertion that each one is present), read from a file +-- descriptor copy.sh holds open, so no file is written. It is applied inside the +-- same transaction, so the dry run proves the copy against the schema production +-- will have, and the rollback takes it away again. \set ON_ERROR_STOP on \if :{?dry_run_baseline} @@ -47,7 +50,7 @@ CREATE TEMP TABLE import_users ( BEGIN; \if :{?dry_run_baseline} -\ir ../../../../packages/auth/drizzle/0000_auth_schema.sql +\i :dry_run_baseline \endif -- Guards. Each aborts the whole transaction, naming a count, never an address. @@ -56,10 +59,10 @@ DECLARE n bigint; BEGIN IF EXISTS (SELECT 1 FROM auth.users) THEN - RAISE EXCEPTION 'cutover copy: auth.users is not empty; empty the auth database and re-run from the 0000 baseline'; + RAISE EXCEPTION 'cutover copy: auth.users is not empty; empty the auth database and re-run from the auth journal (step 5)'; END IF; IF EXISTS (SELECT 1 FROM auth.account) THEN - RAISE EXCEPTION 'cutover copy: auth.account is not empty; empty the auth database and re-run from the 0000 baseline'; + RAISE EXCEPTION 'cutover copy: auth.account is not empty; empty the auth database and re-run from the auth journal (step 5)'; END IF; SELECT count(*) INTO n FROM import_users; IF n = 0 THEN diff --git a/apps/auth/scripts/cutover/README.md b/apps/auth/scripts/cutover/README.md index 261fa07d..18c63950 100644 --- a/apps/auth/scripts/cutover/README.md +++ b/apps/auth/scripts/cutover/README.md @@ -42,8 +42,11 @@ because it is the one place both databases are named. **Dry run: `copy.sh --dry-run`.** The same copy, guards and audits, with nothing committed: `02`'s transaction ends in `ROLLBACK` after printing the auth-side audit from inside it (another session cannot see uncommitted rows). If the auth database has no schema yet (staging and -production before runbook step 5), the `0000` baseline is applied inside the same transaction and -rolled back with it, so step 1's empty-database check still holds afterwards. The script then +production before runbook step 5), the **whole auth journal**, in `_journal.json` order +(`auth-journal.mjs sql`: every migration, then an assertion that each one is present), is applied +inside the same transaction and rolled back with it, so the dry run proves the copy against the +schema production will have, and step 1's empty-database check still holds afterwards. The SQL +reaches `psql` through a file descriptor `copy.sh` holds open, so no file is written. The script then checks that the auth database is as it was (no `auth` schema, or zero users and accounts) and ends with `copy.sh: dry run audit OK (nothing committed)`. Every operator-environment check still runs, because every email and hash still streams through this machine. So a dry run against @@ -63,6 +66,7 @@ also prints the legacy admins' addresses, because the owner approves that list b | `03-audit-app.sql`, `03-audit-auth.sql` | app, auth | no | The same `key\|value` lines on each side: rows, id checksum, email checksum, hash checksum, credential count, system-domain rows | | `03-audit-auth-query.sql` | auth | no | The auth-side audit query, included by `03-audit-auth.sql` (in a read-only transaction) and by `02` under `--dry-run`. Never run on its own | | `operator-env.sh` | none | no | Sourced by `copy.sh` and `complete-tombstones.sh`: the operator-environment check above | +| `auth-journal.mjs` | none (prints SQL) | no | The auth journal in `_journal.json` order: `files` (runbook step 5 applies each with its own `psql --single-transaction`), `check` (SQL printing `\|t` per migration from the objects its SQL creates, failing on any missing) and `sql` (every file, then that check as an assertion: `copy.sh --dry-run`'s baseline). A statement kind it cannot check is refused. | | `copy.sh` | both | via `02` (nothing with `--dry-run`) | Refuses anything `operator-env.sh` refuses. Runs `01 \| 02`, then both audits, prints them, and exits non-zero on any difference. `--dry-run`: see above | | `05-audit-admins-auth.sql` | auth | no | The admin set after `grant-admin.ts` (runbook step 8). It must equal the approved list | | `04-tombstones-pending-app.sql` | app | no | The ids of deletions whose `apps/auth` step did not finish, as one array literal | @@ -77,8 +81,8 @@ own transaction (`BEGIN … COMMIT`), so they are run **without** `--single-tran `02` must create its `TEMP` table and read the pipe outside the transaction it then opens. **Re-running.** The copy refuses a non-empty `auth.users` or `auth.account`. A second -attempt starts by emptying the auth database and re-applying `packages/auth/drizzle/0000` -(runbook step 5). The credential backfill is also safe to re-run on its own: `auth.account`'s +attempt starts by emptying the auth database and re-applying the whole auth journal +(runbook step 5, `auth-journal.mjs`). The credential backfill is also safe to re-run on its own: `auth.account`'s unique `(user_id, provider_id)` turns a second credential for a user into a no-op (`ON CONFLICT … DO NOTHING`). diff --git a/apps/auth/scripts/cutover/auth-journal.mjs b/apps/auth/scripts/cutover/auth-journal.mjs new file mode 100644 index 00000000..0dfad30f --- /dev/null +++ b/apps/auth/scripts/cutover/auth-journal.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// The auth database's migration journal, as the cutover applies and checks it +// (spec 329, main architect 2026-10-28): every migration in +// packages/auth/drizzle/meta/_journal.json, in `idx` order, so a later +// migration needs no runbook edit. +// +// node apps/auth/scripts/cutover/auth-journal.mjs files the SQL files, one per line, in order (relative to the cwd) +// node apps/auth/scripts/cutover/auth-journal.mjs check SQL that prints `|t` per migration +// and fails unless every one is applied +// node apps/auth/scripts/cutover/auth-journal.mjs sql every file in order, then the check as +// one assertion (copy.sh --dry-run's baseline) +// +// The check is derived from the SQL itself: each migration is applied when +// every schema, table, column, constraint and index it creates exists. A +// statement of any other kind is refused here, loudly, rather than left +// unchecked. Plain Node, no dependencies: it runs in the operator environment. +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DRIZZLE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../packages/auth/drizzle'); + +const die = (message) => { + process.stderr.write(`auth-journal: ${message}\n`); + process.exit(2); +}; + +/** The journal's migrations, in `idx` order, each with its file. */ +export function migrations(drizzle = DRIZZLE) { + const { entries } = JSON.parse(readFileSync(resolve(drizzle, 'meta/_journal.json'), 'utf8')); + if (!Array.isArray(entries) || entries.length === 0) throw new Error('the journal has no entries'); + return [...entries] + .sort((a, b) => a.idx - b.idx) + .map(({ idx, tag }, i) => { + if (idx !== i) throw new Error(`journal idx ${idx} out of sequence (expected ${i})`); + const file = resolve(drizzle, `${tag}.sql`); + if (!existsSync(file)) throw new Error(`${tag}.sql is in the journal but not on disk`); + return { tag, file }; + }); +} + +const literal = (value) => `'${value.replace(/'/g, "''")}'`; + +/** One SQL boolean per object a statement creates; throws on a statement it does not know. */ +export function objectChecks(sql, tag = 'migration') { + return sql + .split('--> statement-breakpoint') + .map((statement) => statement.replace(/^\s*--.*$/gm, '').trim()) + .filter(Boolean) + .flatMap((statement) => { + let m; + if ((m = /^CREATE SCHEMA "([^"]+)"/.exec(statement))) { + return [`exists (select 1 from pg_catalog.pg_namespace where nspname = ${literal(m[1])})`]; + } + if ((m = /^CREATE TABLE "([^"]+)"\."([^"]+)"/.exec(statement))) { + return [`to_regclass(${literal(`"${m[1]}"."${m[2]}"`)}) is not null`]; + } + if ((m = /^CREATE (?:UNIQUE )?INDEX "([^"]+)" ON "([^"]+)"\./.exec(statement))) { + return [`to_regclass(${literal(`"${m[2]}"."${m[1]}"`)}) is not null`]; + } + if ((m = /^ALTER TABLE "([^"]+)"\."([^"]+)" ADD COLUMN "([^"]+)"/.exec(statement))) { + return [ + `exists (select 1 from information_schema.columns where table_schema = ${literal(m[1])} and table_name = ${literal(m[2])} and column_name = ${literal(m[3])})`, + ]; + } + if ((m = /^ALTER TABLE "([^"]+)"\."([^"]+)" ADD CONSTRAINT "([^"]+)"/.exec(statement))) { + return [ + `exists (select 1 from pg_catalog.pg_constraint c join pg_catalog.pg_class r on r.oid = c.conrelid join pg_catalog.pg_namespace n on n.oid = r.relnamespace where n.nspname = ${literal(m[1])} and r.relname = ${literal(m[2])} and c.conname = ${literal(m[3])})`, + ]; + } + throw new Error(`${tag}: a statement the journal check does not know: ${statement.split('\n')[0].slice(0, 80)}`); + }); +} + +/** `(tag, applied)` rows for the whole journal, as a VALUES list. */ +function appliedRows(list) { + return list + .map(({ tag, file }) => { + const checks = objectChecks(readFileSync(file, 'utf8'), tag); + if (checks.length === 0) throw new Error(`${tag}: creates nothing the check can see`); + return `(${literal(tag)}, ${checks.map((c) => `(${c})`).join('\n and ')})`; + }) + .join(',\n '); +} + +/** Prints `|t` (or `|f`) per migration in order, then fails unless every one is applied. */ +export function checkSql(list = migrations()) { + const rows = appliedRows(list); + return `select tag, applied from (values\n ${rows}\n) as journal(tag, applied) order by tag; +do $check$ +declare missing text; +begin + select string_agg(tag, ', ' order by tag) into missing from (values\n ${rows}\n) as journal(tag, applied) where not applied; + if missing is not null then + raise exception 'auth journal check: not applied: %', missing; + end if; +end $check$; +`; +} + +/** Every migration in order, then the same assertion, with a notice naming what is present. */ +export function baselineSql(list = migrations()) { + const rows = appliedRows(list); + const files = list.map(({ tag, file }) => `-- ${tag}\n${readFileSync(file, 'utf8').trim()}\n`).join('\n'); + return `${files} +do $check$ +declare missing text; present text; +begin + select string_agg(tag, ', ' order by tag) filter (where not applied), + string_agg(tag, ', ' order by tag) filter (where applied) + into missing, present + from (values\n ${rows}\n) as journal(tag, applied); + if missing is not null then + raise exception 'auth journal check: not applied: %', missing; + end if; + raise notice 'auth journal check: applied inside this transaction: %', present; +end $check$; +`; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const command = process.argv[2]; + if (command === 'files') process.stdout.write(`${migrations().map((m) => relative(process.cwd(), m.file)).join('\n')}\n`); + else if (command === 'check') process.stdout.write(checkSql()); + else if (command === 'sql') process.stdout.write(baselineSql()); + else die('usage: auth-journal.mjs files | check | sql'); + } catch (err) { + die(err instanceof Error ? err.message : String(err)); + } +} diff --git a/apps/auth/scripts/cutover/copy.sh b/apps/auth/scripts/cutover/copy.sh index 5f4476da..b2e8d60b 100755 --- a/apps/auth/scripts/cutover/copy.sh +++ b/apps/auth/scripts/cutover/copy.sh @@ -17,8 +17,11 @@ # `--dry-run` runs the same copy, guards and audits, and commits nothing: 02's # transaction ends in ROLLBACK after printing the auth-side audit from inside # it. Against an auth database with no schema yet (the window before step 5), -# the 0000 baseline is applied inside that transaction too, so the database is -# left as found; the script checks that it was. The rows still stream through +# the WHOLE auth journal, in _journal.json order (auth-journal.mjs), is applied +# inside that transaction too, so the dry run proves the copy against the +# schema production will have, and the database is left as found; the script +# checks that it was. The journal SQL reaches psql through a file descriptor, +# so this still writes no file. The rows still stream through # this machine, so every operator-environment check still applies. # # Usage: copy.sh [--dry-run] [--allow-local] @@ -53,13 +56,17 @@ if [ "$dry_run" = 1 ]; then baseline_vars=() no_schema="$(psql_auth -At -c "select to_regclass('auth.users') is null")" if [ "$no_schema" = "t" ]; then - echo "copy.sh: DRY RUN: the auth database has no schema; the 0000 baseline is applied inside the transaction and rolled back with it" >&2 - baseline_vars=(-v dry_run_baseline=1) + echo "copy.sh: DRY RUN: the auth database has no schema; the whole auth journal (_journal.json order) is applied inside the transaction and rolled back with it" >&2 + # Read the journal once here so an unreadable one stops the run before any pipe. + node "$here/auth-journal.mjs" files >/dev/null || die "the auth journal could not be read" + exec 9< <(node "$here/auth-journal.mjs" sql) + baseline_vars=(-v dry_run_baseline=/dev/fd/9) fi echo "copy.sh: dry run: copying $expected non-system user(s) (app → auth)" auth_audit="$(psql_app -f "$here/01-export-app.sql" \ | psql_auth -v expected_rows="$expected" -v dry_run=1 ${baseline_vars[@]+"${baseline_vars[@]}"} -f "$here/02-import-auth.sql")" if [ "${#baseline_vars[@]}" -gt 0 ]; then + exec 9<&- left="$(psql_auth -At -c "select count(*) from information_schema.schemata where schema_name = 'auth'")" else left="$(psql_auth -At -c "select (select count(*) from auth.users) + (select count(*) from auth.account)")" @@ -84,7 +91,7 @@ if [ "$app_audit" != "$auth_audit" ]; then if [ "$dry_run" = 1 ]; then echo "copy.sh: DRY RUN AUDIT MISMATCH — the real copy would fail its audit here; nothing was committed" >&2 else - echo "copy.sh: AUDIT MISMATCH — do NOT run the app window migration; empty the auth database and start again from the 0000 baseline" >&2 + echo "copy.sh: AUDIT MISMATCH — do NOT run the app window migration; empty the auth database and start again from the auth journal (step 5)" >&2 fi diff <(echo "$app_audit") <(echo "$auth_audit") >&2 || true exit 1 diff --git a/apps/auth/scripts/cutover/rehearsal-anomalies.ts b/apps/auth/scripts/cutover/rehearsal-anomalies.ts index 6df82378..51285dbc 100644 --- a/apps/auth/scripts/cutover/rehearsal-anomalies.ts +++ b/apps/auth/scripts/cutover/rehearsal-anomalies.ts @@ -30,6 +30,8 @@ */ import { createRequire } from 'node:module'; +import { urlHost } from '../url-host'; + // apps/auth depends on bcrypt but not on its type package (the library code // that uses it lives in packages/auth); these are the two calls this script makes. const bcrypt = createRequire(import.meta.url)('bcrypt') as { @@ -49,16 +51,6 @@ const forwarderHost = (env: CutoverEnv) => `^[a-z0-9]([a-z0-9-]*[a-z0-9])?-${env}-tailscale-forwarder(-[0-9]+)?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?\\.ts\\.net)?\\.?$` ); -/** The host of a postgres URL, lower-cased, or null when it does not parse. */ -export function urlHost(url: string | undefined): string | null { - if (!url) return null; - try { - return new URL(url).hostname.toLowerCase() || null; - } catch { - return null; - } -} - export function isForwarderHost(host: string | null, env: CutoverEnv): boolean { return host !== null && forwarderHost(env).test(host); } diff --git a/apps/auth/scripts/grant-admin.ts b/apps/auth/scripts/grant-admin.ts index 9d894a7f..6418b495 100644 --- a/apps/auth/scripts/grant-admin.ts +++ b/apps/auth/scripts/grant-admin.ts @@ -17,12 +17,13 @@ * the person they mean, and only `--confirm ` grants. An id that is not * the account the email resolves to grants nothing; the show step exits 2, so * a script that forgets --confirm stops. It warns on a recent or unverified - * account (the squat signatures), never creates one, and refuses the system - * domain. A demotion takes effect at - * once: apps/api asks for the role on each admin request. + * account (the squat signatures), never creates one, and never grants to the + * system domain or the noreply domain (spec 329). `--revoke` is not refused: a + * demotion only lowers a role, and it is the runbook's repair for a WhatsApp + * user found holding `admin`. A demotion takes effect at once: apps/api asks for the role on each admin request. */ import 'dotenv/config'; -import { createDb, isSystemAddress, normalizeEmail, users, type Db } from '@ansari/auth'; +import { createDb, isNoreplyAddress, isSystemAddress, normalizeEmail, users, type Db } from '@ansari/auth'; export type GrantAdminCommand = | { kind: 'show'; email: string } @@ -66,6 +67,14 @@ export class SystemAddressError extends Error { } } +/** Service and WhatsApp users (spec 329) never sign in and never hold a role. */ +export class NoreplyAddressError extends Error { + constructor() { + super('A noreply-domain address is a service or WhatsApp user and is never granted admin.'); + this.name = 'NoreplyAddressError'; + } +} + /** An account younger than this is flagged: a squatted address is usually a fresh sign-up. */ export const RECENT_ACCOUNT_MS = 7 * 24 * 60 * 60 * 1000; @@ -110,6 +119,7 @@ export async function runGrantAdmin( return { lines: [`Revoked admin from ${email} (role=user).`], exitCode: 0 }; } if (isSystemAddress(email)) throw new SystemAddressError(); + if (isNoreplyAddress(email)) throw new NoreplyAddressError(); const user = await users.findUserByEmail(db, email); if (!user) throw new users.UserNotFoundError(); const warnings = squatWarnings(user, now); @@ -143,7 +153,8 @@ export function scriptError(err: unknown): string { if ( err instanceof users.UserNotFoundError || err instanceof ConfirmMismatchError || - err instanceof SystemAddressError + err instanceof SystemAddressError || + err instanceof NoreplyAddressError ) { return err.message; } diff --git a/apps/auth/scripts/probes/exchange-outage-probe.ts b/apps/auth/scripts/probes/exchange-outage-probe.ts new file mode 100644 index 00000000..77c195ff --- /dev/null +++ b/apps/auth/scripts/probes/exchange-outage-probe.ts @@ -0,0 +1,178 @@ +/** + * The production-shaped run for the WhatsApp exchange (spec 329, plan phase 6): + * apps/auth's real app, with NODE_ENV=production and the real node-postgres + * pool, against a throwaway Postgres container that the probe stops and + * starts under the running service. pglite has one connection and cannot be + * stopped, so none of this can be shown there. Operator-run: + * + * docker run -d --name probe-pg-329-outage -e POSTGRES_PASSWORD=probe -p 55433:5432 postgres:18 + * pnpm --filter ansari-auth exec tsx scripts/probes/exchange-outage-probe.ts \ + * postgres://postgres:probe@127.0.0.1:55433/postgres probe-pg-329-outage + * docker rm -f probe-pg-329-outage + * + * The container is started WITHOUT --rm, so `docker stop` keeps it for the + * restart. The URL and the container name are arguments, never read from the + * environment; the URL must name 127.0.0.1 or localhost. It drops and + * recreates the `auth` schema. + * + * Steps, each under a deadline (PASS or FAIL, never a hang): + * 1. boot assertion passes; a new BSUID → 200 + * 2. the user's row held FOR UPDATE from another connection → 500 within the lock timeout + * 3. the container stopped: inside APIKEY_RELATION_CHECK_TTL an unknown key is + * the stated 401 residual; after it, a valid and an unknown key both → 500 + * 4. the boot assertion with the database down lets the service start + * 5. the container started again: /health recovers and the exchange → 200 + * + * It prints statuses and timings only, never a key, BSUID or token. The output + * is pasted into the review (codev/reviews/329-*.md). + */ +import { execFileSync } from 'node:child_process'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { createDb, limits, serviceUsers } from '@ansari/auth'; + +import { + connect, + localUrlFromArgs, + resetSchema, + serverVersion, + setProbeEnv, +} from '../../../../packages/auth/scripts/probes/probe-db'; +import { createApp, createServiceAuth } from '../../src/app'; +import { assertSchema } from '../../src/boot'; +import { WHATSAPP_SESSION_PATH } from '../../src/routes/whatsapp'; + +const DEADLINE_MS = 20_000; +let failures = 0; + +function record(step: string, ok: boolean, detail: string): void { + if (!ok) failures += 1; + console.log(`${ok ? 'PASS' : 'FAIL'} ${step}: ${detail}`); +} + +async function within(ms: number, work: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`no result within ${ms} ms`)), ms); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const { url } = localUrlFromArgs('exchange-outage-probe.ts'); +const container = process.argv[3]; +if (!container) { + console.error('Usage: exchange-outage-probe.ts '); + process.exit(2); +} + +console.log(`Postgres ${await serverVersion(url)}; container ${container}`); +await resetSchema(url); +setProbeEnv(url); +process.env.NODE_ENV = 'production'; + +const db = createDb(); +const auth = createServiceAuth(db); +const owner = await serviceUsers.createServiceUser(db, 'whatsapp'); +const { key } = await auth.api.createApiKey({ + body: { userId: owner.id, prefix: 'ansari_wa_', permissions: { whatsapp: ['mint'] }, rateLimitEnabled: false }, +}); +const unknown = `ansari_wa_${'Z'.repeat(64)}`; +const bsuid = `US.${Date.now()}`; + +const bootOk = await assertSchema(db); +record('1a boot assertion, schema present', bootOk, `assertSchema → ${bootOk}`); + +const server = await new Promise((resolve) => { + const s = createApp({ auth, db }).listen(0, () => resolve(s)); +}); +const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + +async function exchange(bearer: string): Promise<{ status: number; ms: number }> { + const started = Date.now(); + const res = await within( + DEADLINE_MS, + fetch(`${base}${WHATSAPP_SESSION_PATH}`, { + method: 'POST', + headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' }, + body: JSON.stringify({ bsuid }), + }) + ); + await res.arrayBuffer(); + return { status: res.status, ms: Date.now() - started }; +} + +async function step(name: string, expected: number[], bearer: string): Promise { + try { + const { status, ms } = await exchange(bearer); + record(name, expected.includes(status), `${status} in ${ms} ms (expected ${expected.join(' or ')})`); + } catch (err) { + record(name, false, err instanceof Error ? err.message : 'unknown'); + } +} + +try { + await step('1b new BSUID, valid key', [200], key); + + // 2. Another connection holds the user's row with a lock that conflicts + // with FOR NO KEY UPDATE; lock_timeout must turn the wait into a 500. + const holder = await connect(url); + try { + await holder.query('BEGIN'); + await holder.query( + `SELECT 1 FROM auth.users WHERE id = (SELECT user_id FROM auth.account WHERE provider_id = 'whatsapp' AND account_id = $1) FOR UPDATE`, + [bsuid] + ); + const { status, ms } = await exchange(key); + record( + '2 user row held FOR UPDATE elsewhere', + status === 500 && ms < limits.EXCHANGE_LOCK_TIMEOUT + 3_000, + `${status} in ${ms} ms (expected 500 within lock_timeout ${limits.EXCHANGE_LOCK_TIMEOUT} ms + slack)` + ); + } finally { + await holder.query('ROLLBACK').catch(() => {}); + await holder.end(); + } + + // 3. Prime the relation-check cache, then stop the database under the service. + await step('3a unknown key, database up (primes the relation check)', [401], unknown); + execFileSync('docker', ['stop', container], { stdio: 'ignore' }); + console.log(` docker stop ${container}`); + await step('3b unknown key inside APIKEY_RELATION_CHECK_TTL (stated residual)', [401, 500], unknown); + await sleep(limits.APIKEY_RELATION_CHECK_TTL + 500); + await step('3c valid key after the TTL', [500], key); + await step('3d unknown key after the TTL', [500], unknown); + + // 4. A fresh pool, as a deploy would boot with the database down. + const coldDb = createDb(); + const starts = await within(DEADLINE_MS, assertSchema(coldDb)); + record('4 boot assertion, database down', starts, `assertSchema → ${starts} (expected true: start, /health reports)`); + await coldDb.$client.end(); + const health = await fetch(`${base}/health`); + record('4b /health with the database down', health.status === 503, `${health.status}`); + + // 5. Start the container again; the same running service recovers. + execFileSync('docker', ['start', container], { stdio: 'ignore' }); + console.log(` docker start ${container}`); + let healthy = false; + const deadline = Date.now() + 60_000; + while (!healthy && Date.now() < deadline) { + healthy = (await fetch(`${base}/health`)).status === 200; + if (!healthy) await sleep(500); + } + record('5a /health recovers without a restart', healthy, healthy ? '200' : 'still down after 60 s'); + await step('5b valid key after recovery', [200], key); + await step('5c unknown key after recovery', [401], unknown); +} finally { + await new Promise((r) => server.close(r)); + await db.$client.end(); +} + +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILED`); +process.exit(failures === 0 ? 0 : 1); diff --git a/apps/auth/scripts/probes/service-keys-demo.ts b/apps/auth/scripts/probes/service-keys-demo.ts new file mode 100644 index 00000000..57f3f0dd --- /dev/null +++ b/apps/auth/scripts/probes/service-keys-demo.ts @@ -0,0 +1,87 @@ +/** + * Runnable harness for the service-keys script (spec 329, plan phase 4): a + * throwaway pglite auth database built from packages/auth's journal, every + * subcommand run in the runbook's order, and each output printed as the + * operator would see it. The documented output is checked by running this, + * not by reading it. Nothing leaves the process: Sentry events go to a + * recorder that prints them. The database URL is the placeholder the test + * env sets; its user, password and path must not appear below. + * + * pnpm --filter ansari-auth exec tsx scripts/probes/service-keys-demo.ts + */ +import { createAuth, limits } from '@ansari/auth'; + +import { drizzleFor, migrate, setAuthEnv } from '../../../../packages/auth/tests/helpers/auth-db'; +import { serviceKeys, type KeyEvent, type Recorder } from '../service-keys'; + +const recorder: Recorder = { + capture(event: KeyEvent) { + console.log(` [sentry event] ${JSON.stringify(event)}`); + }, + flush: async () => true, +}; + +const initial = limits.WHATSAPP_KEY_INITIAL; +const LIMITS = [ + '--rate-max', String(initial.rateLimitMax), + '--rate-window', String(initial.rateLimitTimeWindow / 1000), + '--daily', String(initial.refillAmount), +]; + +async function main(): Promise { + setAuthEnv('http://localhost:3100'); + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error('setAuthEnv sets DATABASE_URL'); + const client = await migrate(); + const db = drizzleFor(client); + const auth = createAuth(db); + const transcript: string[] = []; + + async function step(argv: string[]): Promise { + console.log(`\n$ tsx scripts/service-keys.ts ${argv.join(' ')}`); + const { lines, exitCode } = await serviceKeys(argv, { + databaseUrl, + connect: () => ({ db, auth }), + recorder, + print: (line) => console.log(line), + }); + console.log(`(exit ${exitCode})`); + transcript.push(...lines); + return lines; + } + + const idIn = (lines: string[], pattern: RegExp) => { + const id = lines.map((l) => pattern.exec(l)?.[1]).find(Boolean); + if (!id) throw new Error(`no match for ${pattern} in the output`); + return id; + }; + + await step(['create-service', 'whatsapp']); + await step(['create-service', 'whatsapp', '--confirm', 'wrong']); + await step(['create-service', 'whatsapp', '--confirm', 'whatsapp']); + const shown = await step(['create-key', 'whatsapp', ...LIMITS, '--client-id', 'whatsapp-service']); + const userId = idIn(shown, /^ {2}id {10}([0-9a-f-]{36})$/); + const first = idIn(await step(['create-key', 'whatsapp', ...LIMITS, '--client-id', 'whatsapp-service', '--confirm', userId]), /^Created key ([0-9a-f-]{36})/); + await step(['list', 'whatsapp']); + await step(['rotate', 'whatsapp', ...LIMITS, '--confirm', userId]); + await step(['update-limits', 'whatsapp', '--key', first, '--rate-max', '120', '--rate-window', '60', '--daily', '6000', '--confirm', first]); + await step(['update-limits', 'whatsapp', '--key', first, '--rate-max', '100001', '--rate-window', '60', '--daily', '6000', '--confirm', first]); + await step(['create-key', 'whatsapp', ...LIMITS, '--expires-in', '86400', '--confirm', userId]); + await step(['revoke', 'whatsapp', '--key', first]); + await step(['revoke', 'whatsapp', '--key', first, '--confirm', first]); + await step(['list', 'whatsapp']); + await client.close(); + + const text = transcript.join('\n'); + const { username, password, pathname, protocol } = new URL(databaseUrl); + const leaks = [databaseUrl, `${username}:${password}@`, `${pathname}`, `${protocol}//`].filter((s) => text.includes(s)); + const keys = text.match(/ansari_wa_[A-Za-z]{64}/g) ?? []; + console.log(`\nCHECK database printed as hostname:port only: ${leaks.length === 0 ? 'PASS' : `FAIL (${leaks.join(', ')})`}`); + console.log(`CHECK two keys created, each printed once: ${keys.length === 2 && new Set(keys).size === 2 ? 'PASS' : `FAIL (${keys.length})`}`); + process.exit(leaks.length === 0 && keys.length === 2 ? 0 : 1); +} + +main().catch((err: unknown) => { + console.error('demo failed:', err instanceof Error ? err.name : 'unknown'); + process.exit(1); +}); diff --git a/apps/auth/scripts/service-keys.ts b/apps/auth/scripts/service-keys.ts new file mode 100644 index 00000000..3036b16d --- /dev/null +++ b/apps/auth/scripts/service-keys.ts @@ -0,0 +1,639 @@ +/** + * Service users and their API keys (spec 329). The api-key plugin's HTTP + * surface is blocked, so this script is the only way to create a service user + * or to issue, list, rotate, change or revoke its keys. It runs against the + * AUTH database (`DATABASE_URL` in apps/auth's env) and needs apps/auth's full + * env, since keys are created through Better Auth in process. + * + * Usage (from apps/auth/): + * tsx scripts/service-keys.ts create-service [--confirm ] + * tsx scripts/service-keys.ts create-key --rate-max --rate-window --daily + * [--name ] [--client-id ] [--confirm ] + * tsx scripts/service-keys.ts list + * tsx scripts/service-keys.ts rotate (the create-key flags) + * tsx scripts/service-keys.ts update-limits --key --rate-max --rate-window + * --daily [--confirm ] + * tsx scripts/service-keys.ts revoke --key [--confirm ] + * + * Every write is two steps, as in grant-admin.ts: without `--confirm` the + * script shows the service user and what it would do, writes nothing and + * exits 2; only `--confirm ` writes: the service user's id for + * create-key and rotate, the KEY's id for update-limits and revoke (so a + * confirm copied from another key's show step never writes), and the service + * name for create-service, which has no user yet. A mismatch writes nothing + * and exits 2. `--name` and `--client-id` take 1 to 64 letters, digits, + * spaces, dots, underscores and hyphens, and never a key-shaped value: both + * are printed by list and sent to Sentry. + * + * - It prints the database as `hostname:port` only, never the URL. + * - Permissions and the key prefix are fixed per service (SERVICES); limits are + * required flags with no defaults, checked against limits.ts's bounds. The + * budget is daily and always refills: a budget without a refill deletes the + * key the first time it is exhausted. There is no expiry option: a key lives + * until it is rotated and revoked, as the runbook schedules. + * - The raw key is printed once, by create-key or rotate; nothing else prints + * a key or a hash. Revoke sets `enabled = false` and keeps the row. + * - Every write sends one Sentry event (action, service, ids, name; no key + * material). If it cannot be flushed, or `SENTRY_DSN` is not set (nothing is + * attempted then), the write stands, a banner asks for the runbook's key + * log, and the script exits 3. + */ +import 'dotenv/config'; +import * as Sentry from '@sentry/node'; + +import { + createAuth, + createDb, + getEnv, + limits, + serviceAddress, + serviceUsers, + type Auth, + type Db, +} from '@ansari/auth'; + +import { urlHostPort } from './url-host'; + +type ServiceName = serviceUsers.ServiceName; +type KeyLimits = limits.KeyLimits; + +type KeyFlags = { limits: KeyLimits; name: string | null; clientId: string | null }; + +export type ServiceKeysCommand = + | { kind: 'create-service'; service: ServiceName; confirm: string | null } + | ({ kind: 'create-key'; service: ServiceName; confirm: string | null } & KeyFlags) + | ({ kind: 'rotate'; service: ServiceName; confirm: string | null } & KeyFlags) + | { kind: 'list'; service: ServiceName } + | { kind: 'update-limits'; service: ServiceName; keyId: string; limits: KeyLimits; confirm: string | null } + | { kind: 'revoke'; service: ServiceName; keyId: string; confirm: string | null }; + +export const USAGE = [ + 'Usage: tsx scripts/service-keys.ts create-service [--confirm ]', + ' tsx scripts/service-keys.ts create-key --rate-max --rate-window --daily [--name ] [--client-id ] [--confirm ]', + ' tsx scripts/service-keys.ts list ', + ' tsx scripts/service-keys.ts rotate --rate-max --rate-window --daily [--name ] [--client-id ] [--confirm ]', + ' tsx scripts/service-keys.ts update-limits --key --rate-max --rate-window --daily [--confirm ]', + ' tsx scripts/service-keys.ts revoke --key [--confirm ]', + `Services: ${Object.keys(serviceUsers.SERVICES).join(', ')}`, +].join('\n'); + +/** The exit code of a run that wrote nothing on purpose (show step, confirm mismatch). */ +export const EXIT_NOTHING_WRITTEN = 2; +/** The exit code of a write whose Sentry record could not be sent. */ +export const EXIT_UNRECORDED = 3; + +/** How long the script waits for Sentry before it prints the key-log banner. */ +export const SENTRY_FLUSH_TIMEOUT_MS = 5_000; + +const SECOND_MS = 1000; + +/** A refused input or state. Its message is the script's own words, safe to print. */ +export class RefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'RefusedError'; + } +} + +const NO_EXPIRY = + 'Keys have no expiry option: a production key must not die silently. Rotate and revoke it as the runbook schedules.'; +const NO_BARE_BUDGET = + 'A budget without a refill deletes the key the first time it is exhausted. Use --daily, which always refills every 24 h.'; + +const FLAGS_BY_KIND: Record = { + 'create-service': ['--confirm'], + 'create-key': ['--rate-max', '--rate-window', '--daily', '--name', '--client-id', '--confirm'], + rotate: ['--rate-max', '--rate-window', '--daily', '--name', '--client-id', '--confirm'], + list: [], + 'update-limits': ['--key', '--rate-max', '--rate-window', '--daily', '--confirm'], + revoke: ['--key', '--confirm'], +}; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const isKind = (value: string | undefined): value is ServiceKeysCommand['kind'] => + value !== undefined && Object.hasOwn(FLAGS_BY_KIND, value); + +function positiveInteger(flag: string, value: string): number { + const n = Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(n)) { + throw new RefusedError(`${flag} must be a whole number.`); + } + return n; +} + +/** + * The limits a key gets from its flags, all three required. The window is + * given in seconds and stored in milliseconds; the budget always refills daily. + */ +function limitsFrom(flags: Map): KeyLimits { + const missing = ['--rate-max', '--rate-window', '--daily'].filter((f) => !flags.has(f)); + if (missing.length > 0) { + throw new RefusedError(`Limits have no defaults; missing ${missing.join(', ')}.`); + } + return { + rateLimitMax: positiveInteger('--rate-max', flags.get('--rate-max')!), + rateLimitTimeWindow: positiveInteger('--rate-window', flags.get('--rate-window')!) * SECOND_MS, + refillAmount: positiveInteger('--daily', flags.get('--daily')!), + refillInterval: limits.KEY_REFILL_INTERVAL, + }; +} + +/** Parses the command line; throws RefusedError with the reason when it is not a valid command. */ +/** A key name or client id: printed by list and sent to Sentry, so a bounded, inert charset. */ +const LABEL = /^[A-Za-z0-9 ._-]{1,64}$/; + +/** + * `--name` / `--client-id`, refused unless inert. A raw key pasted into the + * wrong flag would be printed by list and sent to Sentry, so anything that + * holds a service's key prefix, or a run of 32 letters (a key body), is refused. + * The value is never echoed back. + */ +function label(flag: string, value: string | undefined): string | null { + if (value === undefined) return null; + if (!LABEL.test(value)) { + throw new RefusedError(`${flag} takes 1 to 64 letters, digits, spaces, dots, underscores and hyphens.`); + } + const lower = value.toLowerCase(); + const keyShaped = + Object.values(serviceUsers.SERVICES).some((s) => lower.includes(s.prefix)) || + Object.values(serviceUsers.SECRET_SCANNING_PATTERNS).some((p) => new RegExp(p.source).test(value)) || + // A key's 64-letter body pasted without its prefix. + /[A-Za-z]{32}/.test(value); + if (keyShaped) throw new RefusedError(`${flag} looks like a key. Never paste a key into a flag; nothing was written.`); + return value; +} + +export function parseArgs(argv: readonly string[]): ServiceKeysCommand { + const [kind, service, ...rest] = argv; + if (!isKind(kind) || service === undefined || service.startsWith('--')) throw new RefusedError(USAGE); + if (!serviceUsers.isServiceName(service)) { + throw new RefusedError(`Unknown service "${service}". Services: ${Object.keys(serviceUsers.SERVICES).join(', ')}.`); + } + const flags = new Map(); + for (let i = 0; i < rest.length; i += 2) { + const [flag = '', value] = [rest[i], rest[i + 1]]; + if (/^--expir/.test(flag)) throw new RefusedError(NO_EXPIRY); + if (flag === '--remaining') throw new RefusedError(NO_BARE_BUDGET); + if (!FLAGS_BY_KIND[kind].includes(flag)) throw new RefusedError(`${flag} is not an option of ${kind}.\n${USAGE}`); + if (flags.has(flag)) throw new RefusedError(`${flag} is given twice.`); + if (value === undefined || value.length === 0 || value.startsWith('--')) { + throw new RefusedError(`${flag} needs a value.`); + } + flags.set(flag, value); + } + const confirm = flags.get('--confirm') ?? null; + const keyId = (): string => { + const id = flags.get('--key'); + if (id === undefined) throw new RefusedError(`${kind} needs --key (see list).`); + if (!UUID.test(id)) throw new RefusedError('--key must be a key id as list prints it.'); + return id.toLowerCase(); + }; + switch (kind) { + case 'create-service': + return { kind, service, confirm }; + case 'list': + return { kind, service }; + case 'create-key': + case 'rotate': + return { + kind, + service, + confirm, + limits: limitsFrom(flags), + name: label('--name', flags.get('--name')), + clientId: label('--client-id', flags.get('--client-id')), + }; + case 'update-limits': + return { kind, service, keyId: keyId(), limits: limitsFrom(flags), confirm }; + case 'revoke': + return { kind, service, keyId: keyId(), confirm }; + } +} + +/** + * The last check before the plugin is called: inside the service's bounds, + * and a budget only with its daily refill. A key whose `remaining` has no + * refill is deleted by the plugin on exhaustion. + */ +export function checkKeyLimits(service: ServiceName, values: Partial): KeyLimits { + const { rateLimitMax, rateLimitTimeWindow, refillAmount, refillInterval } = values; + if (refillAmount === undefined || refillInterval !== limits.KEY_REFILL_INTERVAL) { + throw new RefusedError(NO_BARE_BUDGET); + } + if (rateLimitMax === undefined || rateLimitTimeWindow === undefined) { + throw new RefusedError('A key needs a rate limit: --rate-max and --rate-window.'); + } + const bounds = serviceUsers.SERVICES[service].bounds; + const outside = (value: number, b: { min: number; max: number }) => value < b.min || value > b.max; + const problems = [ + outside(rateLimitMax, bounds.rateLimitMax) && + `--rate-max must be ${bounds.rateLimitMax.min} to ${bounds.rateLimitMax.max}`, + outside(rateLimitTimeWindow, bounds.rateLimitTimeWindow) && + `--rate-window must be ${bounds.rateLimitTimeWindow.min / SECOND_MS} to ${bounds.rateLimitTimeWindow.max / SECOND_MS} seconds`, + outside(refillAmount, bounds.refillAmount) && + `--daily must be ${bounds.refillAmount.min} to ${bounds.refillAmount.max}`, + ].filter((p): p is string => typeof p === 'string'); + if (problems.length > 0) throw new RefusedError(`Out of bounds (limits.ts): ${problems.join('; ')}.`); + return { rateLimitMax, rateLimitTimeWindow, refillAmount, refillInterval }; +} + +/** One durable record of a write: never key material. */ +export type KeyEvent = { + action: Exclude; + service: ServiceName; + userId: string; + keyId: string | null; + name: string | null; +}; + +export type ScriptResult = { lines: string[]; exitCode: number; events: KeyEvent[] }; + +export type ScriptContext = { db: Db; auth: Auth; now?: Date }; + +const describeLimits = (l: { + rateLimitMax: number | null; + rateLimitTimeWindow: number | null; + refillAmount: number | null; + refillInterval: number | null; +}) => + `${l.rateLimitMax} per ${(l.rateLimitTimeWindow ?? 0) / SECOND_MS} s; ` + + `budget ${l.refillAmount} per ${(l.refillInterval ?? 0) / 3_600_000} h`; + +const iso = (d: Date | null) => (d ? d.toISOString() : 'never'); + +function keyLines(key: serviceUsers.ServiceKey): string[] { + return [ + ` key ${key.id}`, + ` name ${key.name ?? ''}`, + ` prefix ${key.prefix ?? ''}`, + ` permissions ${key.permissions ?? ''}`, + ` enabled ${key.enabled}`, + ` limits ${describeLimits(key)}; remaining ${key.remaining}`, + ` created ${iso(key.createdAt)}`, + ` last request ${iso(key.lastRequest)}`, + ]; +} + +const command = (argv: string) => ` tsx scripts/service-keys.ts ${argv}`; + +/** The service user every key command acts for; refuses none and many. */ +async function serviceUser(db: Db, service: ServiceName): Promise { + const found = await serviceUsers.findServiceUser(db, service); + if (found.kind === 'none') { + throw new RefusedError(`No service user for "${service}". Create it first: create-service ${service}.`); + } + if (found.kind === 'many') { + throw new RefusedError( + `${found.ids.length} users claim service "${service}" (${found.ids.join(', ')}); refusing to act. Resolve this by hand.` + ); + } + return found.user; +} + +const userLines = (service: ServiceName, user: serviceUsers.ServiceUser) => [ + `Service user for ${service}:`, + ` id ${user.id}`, + ` email ${user.email}`, + ` created_at ${user.createdAt.toISOString()}`, +]; + +const nothingWritten = (lines: string[]): ScriptResult => ({ lines, exitCode: EXIT_NOTHING_WRITTEN, events: [] }); + +const mismatch = (expected: string): ScriptResult => + nothingWritten([ + `The --confirm value is not ${expected}. Nothing was written. Run without --confirm to see it.`, + ]); + +/** Key flags in the form a show step tells the operator to rerun. */ +const flagsText = (l: KeyLimits) => + `--rate-max ${l.rateLimitMax} --rate-window ${l.rateLimitTimeWindow / SECOND_MS} --daily ${l.refillAmount}`; + +const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +async function createKey( + ctx: ScriptContext, + service: ServiceName, + userId: string, + flags: KeyFlags +): Promise<{ id: string; key: string; name: string }> { + const checked = checkKeyLimits(service, flags.limits); + const { permissions, prefix } = serviceUsers.SERVICES[service]; + // Default name: the service and the minute it was created, so a rotation's two keys differ. + const name = flags.name ?? `${service} ${(ctx.now ?? new Date()).toISOString().slice(0, 16)}Z`; + const created = await ctx.auth.api.createApiKey({ + body: { + userId, + prefix, + name, + permissions: Object.fromEntries(Object.entries(permissions).map(([k, v]) => [k, [...v]])), + metadata: { clientId: flags.clientId ?? service }, + rateLimitEnabled: true, + rateLimitTimeWindow: checked.rateLimitTimeWindow, + rateLimitMax: checked.rateLimitMax, + remaining: checked.refillAmount, + refillAmount: checked.refillAmount, + refillInterval: checked.refillInterval, + }, + }); + return { id: created.id, key: created.key, name }; +} + +/** The new key, framed so the operator sees it is shown once and where it goes. */ +const rawKeyLines = (key: string) => [ + '================ NEW KEY: SHOWN ONCE, NEVER STORED ================', + key, + "Put it in the service's credential store now, as the runbook says. It cannot be shown again.", + '===================================================================', +]; + +async function ownedKey(db: Db, userId: string, keyId: string): Promise { + const key = (await serviceUsers.listServiceKeys(db, userId)).find((k) => k.id === keyId); + if (!key) throw new RefusedError(`Key ${keyId} is not a key of this service (see list).`); + return key; +} + +/** Runs one command and returns the lines to print, the exit code and the events to record. */ +export async function runServiceKeys(ctx: ScriptContext, cmd: ServiceKeysCommand): Promise { + const { db } = ctx; + if (cmd.kind === 'create-service') { + const found = await serviceUsers.findServiceUser(db, cmd.service); + if (found.kind !== 'none') { + throw new RefusedError(`Service "${cmd.service}" already has a service user; nothing was written.`); + } + if (cmd.confirm === null) { + return nothingWritten([ + `No service user for ${cmd.service}. This would create:`, + ` email ${serviceAddress(cmd.service)} (no credential, role user)`, + ` account provider service, account id ${cmd.service}`, + 'Nothing was written. To create it, run:', + command(`create-service ${cmd.service} --confirm ${cmd.service}`), + ]); + } + if (cmd.confirm !== cmd.service) return mismatch(`the service name (${cmd.service})`); + const user = await serviceUsers.createServiceUser(db, cmd.service); + return { + lines: [`Created service user ${user.id} (${user.email}) for ${cmd.service}.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: null, name: null }], + }; + } + + const user = await serviceUser(db, cmd.service); + const keys = await serviceUsers.listServiceKeys(db, user.id); + + if (cmd.kind === 'list') { + return { + lines: [...userLines(cmd.service, user), `${keys.length} key(s):`, ...keys.flatMap(keyLines)], + exitCode: 0, + events: [], + }; + } + + if (cmd.kind === 'create-key' || cmd.kind === 'rotate') { + checkKeyLimits(cmd.service, cmd.limits); + const live = keys.filter((k) => k.enabled); + if (cmd.kind === 'rotate' && live.length === 0) { + throw new RefusedError(`Service "${cmd.service}" has no live key to rotate; use create-key.`); + } + const extra = [cmd.name && `--name ${quote(cmd.name)}`, cmd.clientId && `--client-id ${quote(cmd.clientId)}`] + .filter(Boolean) + .join(' '); + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + `${live.length} live key(s): ${live.map((k) => k.id).join(', ') || 'none'}`, + `This would create a key with ${describeLimits(cmd.limits)}.`, + 'Nothing was written. If this is the service you mean, run:', + command(`${cmd.kind} ${cmd.service} ${flagsText(cmd.limits)}${extra ? ` ${extra}` : ''} --confirm ${user.id}`), + ]); + } + if (cmd.confirm !== user.id) return mismatch(`this service user's id`); + const created = await createKey(ctx, cmd.service, user.id, cmd); + const rotated = + cmd.kind === 'rotate' + ? [ + `Old live key(s): ${live.map((k) => k.id).join(', ')}`, + 'Once the consumer runs on the new key, revoke each old one:', + ...live.map((k) => command(`revoke ${cmd.service} --key ${k.id} --confirm ${k.id}`)), + 'Until then both keys are live and the daily budget is doubled; keep the overlap short.', + ] + : []; + return { + lines: [ + `Created key ${created.id} (${created.name}) for ${cmd.service}: ${describeLimits(cmd.limits)}.`, + ...rawKeyLines(created.key), + ...rotated, + ], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: created.id, name: created.name }], + }; + } + + const key = await ownedKey(db, user.id, cmd.keyId); + if (cmd.kind === 'update-limits') { + const checked = checkKeyLimits(cmd.service, cmd.limits); + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + ...keyLines(key), + `This would set ${describeLimits(checked)}, and today's remaining budget to ${checked.refillAmount}.`, + 'Nothing was written. To apply it, run:', + command(`update-limits ${cmd.service} --key ${key.id} ${flagsText(checked)} --confirm ${key.id}`), + ]); + } + if (cmd.confirm !== key.id) return mismatch(`this key's id`); + // The new budget starts now: `remaining` is set to it with the refill. + await ctx.auth.api.updateApiKey({ + body: { keyId: key.id, userId: user.id, ...checked, remaining: checked.refillAmount }, + }); + return { + lines: [`Updated key ${key.id} (${key.name ?? ''}): ${describeLimits(checked)}.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: key.id, name: key.name }], + }; + } + + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + ...keyLines(key), + 'This would revoke the key (enabled = false; the row is kept).', + 'Nothing was written. To revoke it, run:', + command(`revoke ${cmd.service} --key ${key.id} --confirm ${key.id}`), + ]); + } + if (cmd.confirm !== key.id) return mismatch(`this key's id`); + await ctx.auth.api.updateApiKey({ body: { keyId: key.id, userId: user.id, enabled: false } }); + return { + lines: [`Revoked key ${key.id} (${key.name ?? ''}): enabled = false; the row is kept.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: key.id, name: key.name }], + }; +} + +/** Where the durable record goes: Sentry in the script, a spy transport in tests. */ +export type Recorder = { + capture: (event: KeyEvent) => void; + /** True when every captured event was sent within the timeout. */ + flush: (timeoutMs: number) => Promise; + /** Why nothing can be recorded, when that is known up front (printed in the banner). */ + reason?: string; +}; + +export const KEY_EVENT_MESSAGE = 'auth: service key operator action'; + +export const sentryRecorder: Recorder = { + capture(event) { + Sentry.captureMessage(KEY_EVENT_MESSAGE, { + level: 'info', + tags: { alert: 'auth-service-key', action: event.action, service: event.service }, + extra: { ...event }, + }); + }, + async flush(timeoutMs) { + try { + return await Sentry.flush(timeoutMs); + } catch { + return false; + } + }, +}; + +/** + * No `SENTRY_DSN`: nothing is captured or flushed, on purpose, and every + * write takes the unrecorded path (banner, key log, exit 3). It does not rely + * on an uninitialised Sentry's flush happening to answer false. + */ +export const unrecordedRecorder: Recorder = { + capture() {}, + flush: async () => false, + reason: 'SENTRY_DSN is not set: no record was attempted.', +}; + +/** The script's recorder: Sentry when a DSN is set (initialised here), otherwise none. */ +export function scriptRecorder(dsn: string | undefined): Recorder { + if (!dsn) return unrecordedRecorder; + Sentry.init({ dsn, environment: process.env.NODE_ENV ?? 'development', ...SCRIPT_SENTRY_OPTIONS }); + return sentryRecorder; +} + +function unrecordedBanner(events: KeyEvent[], reason: string | undefined): string[] { + const bar = '!'.repeat(72); + return [ + bar, + '!! THE SENTRY RECORD OF THIS ACTION WAS NOT SENT. THE WRITE ABOVE STANDS.', + ...(reason ? [`!! ${reason}`] : []), + "!! Record it by hand in the runbook's key log now:", + ...events.map( + (e) => `!! action ${e.action}, service ${e.service}, user ${e.userId}, key ${e.keyId ?? '-'}, name ${e.name ?? '-'}` + ), + bar, + ]; +} + +/** A refusal is reported in full; anything else by its name only (no driver detail, no key). */ +export function scriptError(err: unknown): string { + if (err instanceof RefusedError) return err.message; + return err instanceof Error ? err.name : 'unknown'; +} + +export type ScriptDeps = { + databaseUrl: string | undefined; + connect: () => ScriptContext; + recorder: Recorder; + /** Prints one line at once (console.log in the script). */ + print: (line: string) => void; + flushTimeoutMs?: number; +}; + +/** + * The whole script: database URL check, parse, `hostname:port`, run, and the + * durable record. Each line is printed as it is produced, so the database + * host is on the operator's screen before anything connects or writes. + * Returns every printed line and the exit code. + */ +export async function serviceKeys(argv: readonly string[], deps: ScriptDeps): Promise<{ lines: string[]; exitCode: number }> { + const lines: string[] = []; + const emit = (...out: string[]) => { + for (const line of out) { + lines.push(line); + deps.print(line); + } + }; + if (!deps.databaseUrl) { + emit('DATABASE_URL is not set (the AUTH database). Refusing to run.'); + return { lines, exitCode: 1 }; + } + const host = urlHostPort(deps.databaseUrl); + if (host === null) { + emit('DATABASE_URL does not parse as a URL. Refusing to run.'); + return { lines, exitCode: 1 }; + } + emit(`Auth database: ${host}`); + let result: ScriptResult; + try { + const cmd = parseArgs(argv); + result = await runServiceKeys(deps.connect(), cmd); + } catch (err) { + emit(`service-keys refused: ${scriptError(err)}`); + return { lines, exitCode: 1 }; + } + emit(...result.lines); + if (result.events.length === 0) return { lines, exitCode: result.exitCode }; + for (const event of result.events) deps.recorder.capture(event); + if (await deps.recorder.flush(deps.flushTimeoutMs ?? SENTRY_FLUSH_TIMEOUT_MS)) { + return { lines, exitCode: result.exitCode }; + } + emit(...unrecordedBanner(result.events, deps.recorder.reason)); + return { lines, exitCode: EXIT_UNRECORDED }; +} + +/** + * Sentry options for this script. Breadcrumbs are off: with Sentry's default + * Console integration every console line becomes a breadcrumb on the next + * event, and the line before the operator event is the raw key. There is no + * `beforeSend` (unlike `SERVICE_SENTRY_OPTIONS`): a CLI handles no HTTP + * request, so no request data is attached to its events. + */ +export const SCRIPT_SENTRY_OPTIONS = { + sendDefaultPii: false, + maxBreadcrumbs: 0, + beforeBreadcrumb: () => null, +} as const; + +/** + * The script's printer: straight to stdout, not through `console`, which + * Sentry instruments. Breadcrumbs are off as well; either alone keeps the key + * out of an event. + */ +export function printLine(line: string): void { + process.stdout.write(`${line}\n`); +} + +async function main(): Promise { + const recorder = scriptRecorder(process.env.SENTRY_DSN); + const { exitCode } = await serviceKeys(process.argv.slice(2), { + databaseUrl: process.env.DATABASE_URL, + connect: () => { + try { + getEnv(); + } catch (err) { + // Names the missing variables, never a value. + throw new RefusedError(err instanceof Error ? err.message : 'apps/auth env is incomplete'); + } + const db = createDb(); + return { db, auth: createAuth(db) }; + }, + recorder, + print: printLine, + }); + process.exit(exitCode); +} + +if (process.argv[1]?.endsWith('service-keys.ts')) { + main().catch((err: unknown) => { + console.error('service-keys failed:', scriptError(err)); + process.exit(1); + }); +} diff --git a/apps/auth/scripts/url-host.ts b/apps/auth/scripts/url-host.ts new file mode 100644 index 00000000..5ca02404 --- /dev/null +++ b/apps/auth/scripts/url-host.ts @@ -0,0 +1,23 @@ +// What an operator script may say about a database URL: its host, never the +// user, password, path, query string or a parse error (any of which can carry +// a credential). Shared by the cutover rehearsal and the service-keys script. + +/** The host of a postgres URL, lower-cased, or null when it does not parse. */ +export function urlHost(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname.toLowerCase() || null; + } catch { + return null; + } +} + +/** Postgres's port when a URL names none. */ +const DEFAULT_PORT = '5432'; + +/** `hostname:port` of a postgres URL, or null when it does not parse. */ +export function urlHostPort(url: string | undefined): string | null { + const host = urlHost(url); + if (host === null || url === undefined) return null; + return `${host}:${new URL(url).port || DEFAULT_PORT}`; +} diff --git a/apps/auth/src/alerts.ts b/apps/auth/src/alerts.ts index da7f6bb9..b0d08cd8 100644 --- a/apps/auth/src/alerts.ts +++ b/apps/auth/src/alerts.ts @@ -2,6 +2,13 @@ // limits trip for many distinct emails in a short period, someone is likely // walking an email list. The event carries counts and 8-character prefixes of // the email hashes, never an email. +// +// The WhatsApp exchange's alerts (spec 329) keep the same posture: no personal +// identifier in a Sentry event. A per-key alert names the key by its id (a +// plugin-made UUID that identifies a credential, not a person, and that `list` +// prints), so the operator can act on it. The one personal id an operator +// needs, an ineligible user's, goes only in a server log line. +import { limits } from '@ansari/auth'; import * as Sentry from '@sentry/node'; export type ListWalkAlert = { @@ -34,9 +41,52 @@ export type FailedSignInAlert = { export const FAILED_SIGN_IN_MESSAGE = 'auth: failed sign-ins over the alert threshold, every email together (spray?)'; +/** The exchange's path: the route, app.ts, the outage probe and a docs test read it from here. */ +export const WHATSAPP_SESSION_PATH = '/api/whatsapp/session'; + +/** + * Headers an event may keep: none is a credential, and the user agent is one + * of the few things that tells a botnet spray from a broken client. + */ +const KEPT_HEADERS = new Set(['host', 'user-agent', 'content-type']); + +/** + * Sentry's request-data integration attaches the in-flight request (headers, + * cookies, query string and body) to every event raised while it is handled: + * a sign-in's email and password, a bearer. `sendDefaultPii: false` does not + * cover bodies or the Authorization header, so every event keeps only the + * request's method, path and the headers in KEPT_HEADERS. That includes the + * WhatsApp exchange (spec 329): its body carries the BSUID and the phone + * number, and its `Authorization` header the service key. + */ +export function scrubRequestEvent(event: E): E { + const request = event.request; + if (!request) return event; + const headers = Object.fromEntries( + Object.entries(request.headers ?? {}).filter(([name]) => KEPT_HEADERS.has(name.toLowerCase())) + ); + event.request = { method: request.method, url: request.url?.split('?')[0], headers }; + return event; +} + +/** + * The service's Sentry options beside the DSN and environment; tests add only a transport. + * No breadcrumbs, service-wide: an HTTP breadcrumb carries its request's query + * string, and the Console integration records every `console` line as a + * breadcrumb that rides on the next event, so the ineligible-user log line (a + * user id) would reach Sentry with that alert. Nothing here relies on + * breadcrumbs (as `SCRIPT_SENTRY_OPTIONS` for the scripts). + */ +export const SERVICE_SENTRY_OPTIONS = { + sendDefaultPii: false, + maxBreadcrumbs: 0, + beforeBreadcrumb: () => null, + beforeSend: scrubRequestEvent, +} as const; + export function initSentry(dsn: string | undefined, environment: string): void { if (!dsn) return; - Sentry.init({ dsn, environment, sendDefaultPii: false }); + Sentry.init({ dsn, environment, ...SERVICE_SENTRY_OPTIONS }); } /** One Sentry event (plus a log line with the same fields). */ @@ -58,3 +108,137 @@ export function sendListWalkAlert(alert: ListWalkAlert): void { extra: { ...alert }, }); } + +/** + * Every exchange alert, as Sentry receives it: key ids, counts, thresholds and + * windows only. The fire-once kinds (`key-refused`, `unauthorized-volume`, + * `ineligible-user`, `service-user-unresolvable`) mark the first occurrence (or + * the threshold crossing) in their window and carry no count of their own: a + * count read at the moment of firing is always 1 or the threshold. + */ +export type ExchangeAlert = + /** A key's exchanges in its current rate-limit window reached WHATSAPP_EXCHANGE_KEY_ALERT. */ + | { kind: 'key-window-volume'; keyId: string; requests: number; windowSeconds: number; threshold: number } + /** A key's exchanges since its last daily refill reached WHATSAPP_EXCHANGE_DAILY_ALERT. */ + | { kind: 'key-daily-volume'; keyId: string; requests: number; threshold: number } + /** The plugin refused a key for its budget (USAGE_EXCEEDED) or its window (RATE_LIMITED); the caller saw a 401. */ + | { kind: 'key-refused'; code: KeyRefusalCode; windowSeconds: number } + /** 401s reached EXCHANGE_UNAUTHORIZED_ALERT in one window. */ + | { kind: 'unauthorized-volume'; windowSeconds: number; threshold: number } + /** A user behind a `whatsapp` row may not get a token; the user id is in the server log only. */ + | { kind: 'ineligible-user'; windowSeconds: number } + /** The WhatsApp service user is missing or not unique: every exchange is a 500. */ + | { kind: 'service-user-unresolvable'; serviceUsers: number; windowSeconds: number }; + +export type KeyRefusalCode = 'USAGE_EXCEEDED' | 'RATE_LIMITED'; + +export const EXCHANGE_ALERT_MESSAGE = 'auth: WhatsApp exchange alert'; + +/** One Sentry event (plus a log line with the same fields). */ +export function sendExchangeAlert(alert: ExchangeAlert): void { + console.warn(EXCHANGE_ALERT_MESSAGE, alert); + Sentry.captureMessage(`${EXCHANGE_ALERT_MESSAGE}: ${alert.kind}`, { + level: 'warning', + tags: { alert: `whatsapp-exchange-${alert.kind}` }, + extra: { ...alert }, + }); +} + +/** The fields of a verified key the per-key alerts read, as `verifyApiKey` returns them. */ +export type VerifiedKeyUsage = { + id: string; + requestCount: number; + rateLimitTimeWindow: number | null; + remaining: number | null; + refillAmount: number | null; + lastRefillAt: Date | string | null; + createdAt: Date | string; +}; + +/** What the exchange route reports; each call is one request, the alerts decide when to send. */ +export type ExchangeAlerts = { + keyVerified(key: VerifiedKeyUsage): void; + keyRefused(code: KeyRefusalCode): void; + unauthorized(): void; + ineligibleUser(userId: string, reason: string): void; + serviceUserUnresolvable(serviceUsers: number): void; +}; + +export const INELIGIBLE_USER_MESSAGE = 'whatsapp exchange: ineligible user refused (repair per the service-keys runbook)'; + +export type ExchangeAlertOptions = { + send?: (alert: ExchangeAlert) => void; + now?: () => number; +}; + +/** + * The exchange's alerts, deduplicated in process. The per-key counts come + * from the key's own row (the plugin keeps them durably and resets them with + * the window and the refill); a key's window alert fires once per window and + * its daily alert once per refill period. The rest count in fixed + * EXCHANGE_ALERT_WINDOW windows and fire once per window. A restart can + * re-fire once, which errs loud. + */ +export function createExchangeAlerts({ send = sendExchangeAlert, now = Date.now }: ExchangeAlertOptions = {}): ExchangeAlerts { + const windowMs = limits.EXCHANGE_ALERT_WINDOW * 1000; + const keyWindowFiredAt = new Map(); + const keyDailyFiredFor = new Map(); + const windows = new Map(); + + /** Count one event in `name`'s fixed window; true the first time the count reaches `threshold`. */ + function tally(name: string, threshold: number): boolean { + const at = now(); + let window = windows.get(name); + if (!window || at - window.start >= windowMs) { + window = { start: at, count: 0, fired: false }; + windows.set(name, window); + } + window.count += 1; + if (window.fired || window.count < threshold) return false; + window.fired = true; + return true; + } + + const windowSeconds = limits.EXCHANGE_ALERT_WINDOW; + + return { + keyVerified(key) { + const keyWindowMs = key.rateLimitTimeWindow ?? 0; + const firedAt = keyWindowFiredAt.get(key.id); + if ( + key.requestCount >= limits.WHATSAPP_EXCHANGE_KEY_ALERT && + (firedAt === undefined || now() - firedAt >= keyWindowMs) + ) { + keyWindowFiredAt.set(key.id, now()); + send({ + kind: 'key-window-volume', + keyId: key.id, + requests: key.requestCount, + windowSeconds: keyWindowMs / 1000, + threshold: limits.WHATSAPP_EXCHANGE_KEY_ALERT, + }); + } + if (key.refillAmount === null || key.remaining === null) return; + const used = key.refillAmount - key.remaining; + const period = new Date(key.lastRefillAt ?? key.createdAt).toISOString(); + if (used >= limits.WHATSAPP_EXCHANGE_DAILY_ALERT && keyDailyFiredFor.get(key.id) !== period) { + keyDailyFiredFor.set(key.id, period); + send({ kind: 'key-daily-volume', keyId: key.id, requests: used, threshold: limits.WHATSAPP_EXCHANGE_DAILY_ALERT }); + } + }, + keyRefused(code) { + if (tally(`refused:${code}`, 1)) send({ kind: 'key-refused', code, windowSeconds }); + }, + unauthorized() { + const { max } = limits.EXCHANGE_UNAUTHORIZED_ALERT; + if (tally('unauthorized', max)) send({ kind: 'unauthorized-volume', windowSeconds, threshold: max }); + }, + ineligibleUser(userId, reason) { + console.warn(INELIGIBLE_USER_MESSAGE, { userId, reason }); + if (tally('ineligible', 1)) send({ kind: 'ineligible-user', windowSeconds }); + }, + serviceUserUnresolvable(serviceUsers) { + if (tally('service-user', 1)) send({ kind: 'service-user-unresolvable', serviceUsers, windowSeconds }); + }, + }; +} diff --git a/apps/auth/src/app.ts b/apps/auth/src/app.ts index ff29504d..c96b461b 100644 --- a/apps/auth/src/app.ts +++ b/apps/auth/src/app.ts @@ -4,6 +4,7 @@ // // /api/auth/* Better Auth's router (its JWKS at /api/auth/jwks included) // /api/account/* the account endpoints, bound to the caller's own JWT +// /api/whatsapp/session the WhatsApp token exchange, bound to a service key // /health and the diag route import { createAuth, getEnv, type Auth, type Db, type Mailer } from '@ansari/auth'; import { toNodeHandler } from 'better-auth/node'; @@ -17,11 +18,14 @@ import { corsFor, mergeExposedHeaders } from './middleware/cors'; import { ACCOUNT_PATH, accountRouter } from './routes/account'; import { clientIpDiag, DIAG_PATH } from './routes/diag'; import { health } from './routes/health'; +import { WHATSAPP_SESSION_PATH, whatsappRouter, type ExchangeOptions } from './routes/whatsapp'; export type ServiceDeps = { auth: Auth; db: Db }; export type AppOptions = { /** Refuse router requests with no usable client IP. Defaults to NODE_ENV=production. */ production?: boolean; + /** The WhatsApp exchange's alerts and clock; the defaults in production. */ + exchange?: ExchangeOptions; }; const AUTH_ROUTE = '/api/auth{/*path}'; @@ -59,6 +63,11 @@ function notFound(_req: Request, res: Response): void { export function createApp({ auth, db }: ServiceDeps, options: AppOptions = {}): Express { const app = express(); app.disable('x-powered-by'); + // Ahead of corsFor (spec 329): corsFor decides by origin alone and answers + // OPTIONS itself, so a route after it would get CORS for the configured + // origin. The router answers every request to its path, so nothing reaches + // corsFor; a browser can never read a response from it. + app.use(WHATSAPP_SESSION_PATH, whatsappRouter({ auth, db }, options.exchange)); app.use(corsFor(getEnv().CORS_ORIGIN)); app.use(mergeExposedHeaders); app.use(blockedPaths); diff --git a/apps/auth/src/boot.ts b/apps/auth/src/boot.ts new file mode 100644 index 00000000..3f5d4e5c --- /dev/null +++ b/apps/auth/src/boot.ts @@ -0,0 +1,50 @@ +// The boot assertion (spec 329, the #165 class: code shipped before its +// migration). Before listen(), apps/auth checks the schema spec 329 needs: +// the `auth.apikey` relation and `unique(provider_id, account_id)` on +// `auth.account`, both from migration 0001. +// +// It refuses to start ONLY on a definite answer that one is missing: Postgres +// 42P01 for the relation, or no row in the catalog for the constraint. Any +// other failure (the database unreachable, a timeout) lets the service start +// as it always has, with /health reporting the database: a blip at deploy must +// not become a crash loop that takes sign-in down with it. Every log line is +// `{name, code}` only. +import { hasAuthUniqueConstraint, redactError, schema, type Db } from '@ansari/auth'; + +const UNDEFINED_TABLE = '42P01'; + +/** The constraint migration 0001 adds (packages/auth `schema.ts`): one user per BSUID. */ +export const BSUID_CONSTRAINT = 'account_provider_id_account_id_unique'; +const BSUID_CONSTRAINT_TABLE = 'account'; +const BSUID_CONSTRAINT_COLUMNS = ['provider_id', 'account_id'] as const; + +export const SCHEMA_MISSING_MESSAGE = 'auth boot: schema missing, refusing to start (apply migration 0001 first)'; +export const SCHEMA_UNCHECKED_MESSAGE = 'auth boot: schema check failed, starting anyway'; + +function codeOf(err: unknown): unknown { + return err instanceof Error ? redactError(err).code : undefined; +} + +const meta = (err: unknown) => (err instanceof Error ? redactError(err) : { name: 'unknown' }); + +/** True when apps/auth may start: the schema is there, or the database gave no definite answer. */ +export async function assertSchema(db: Db): Promise { + try { + await db.select({ id: schema.apikey.id }).from(schema.apikey).limit(1); + } catch (err) { + if (codeOf(err) === UNDEFINED_TABLE) { + console.error(SCHEMA_MISSING_MESSAGE, meta(err)); + return false; + } + console.error(SCHEMA_UNCHECKED_MESSAGE, meta(err)); + return true; + } + try { + if (await hasAuthUniqueConstraint(db, BSUID_CONSTRAINT_TABLE, BSUID_CONSTRAINT, BSUID_CONSTRAINT_COLUMNS)) return true; + console.error(SCHEMA_MISSING_MESSAGE, { name: 'MissingConstraint', code: BSUID_CONSTRAINT }); + return false; + } catch (err) { + console.error(SCHEMA_UNCHECKED_MESSAGE, meta(err)); + return true; + } +} diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 32087995..2d4aa069 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -8,17 +8,22 @@ import { createDb, getEnv } from '@ansari/auth'; import { initSentry } from './alerts'; import { createApp, createServiceAuth } from './app'; +import { assertSchema } from './boot'; const env = getEnv(); initSentry(env.SENTRY_DSN, env.NODE_ENV); // No admin check at boot (owner amendment 2026-10-17): admin is the role in // auth.users alone, granted by scripts/grant-admin.ts, and the window audits it -// (05-audit-admins-auth.sql), so a stale list can never stop sign-in. +// (05-audit-admins-auth.sql), so a stale list can never stop sign-in. The one +// boot check is the schema (boot.ts), and it stops the service only when a +// migration is definitely missing. const db = createDb(); const auth = createServiceAuth(db); +if (!(await assertSchema(db))) process.exit(1); + createApp({ auth, db }).listen(env.PORT, () => { console.log(`auth service listening on port ${env.PORT}`); }); diff --git a/apps/auth/src/middleware/blocked-paths.ts b/apps/auth/src/middleware/blocked-paths.ts index d885dddb..1c66e3e3 100644 --- a/apps/auth/src/middleware/blocked-paths.ts +++ b/apps/auth/src/middleware/blocked-paths.ts @@ -1,5 +1,6 @@ -// Refuses the admin plugin's HTTP surface, the two profile-changing routes and -// change-password before Better Auth sees the request (spec 300, Hosting and token model). +// Refuses the admin plugin's HTTP surface, the api-key plugin's surface (spec 329), +// the two profile-changing routes and change-password before Better Auth sees the +// request (spec 300, Hosting and token model). // // `disabledPaths` in createAuth() covers the same routes, but better-auth // matches it against the exact normalized path only (dist/api/index.mjs:166-168). @@ -8,14 +9,21 @@ // response: 404 with an empty body. import { posix } from 'node:path'; -import { ADMIN_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; +import { ADMIN_PATHS, API_KEY_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; import type { NextFunction, Request, Response } from 'express'; const ADMIN_PREFIX = '/api/auth/admin'; -// Every blocked route that is not an admin path, from the one list createAuth() +// The api-key plugin's whole surface (spec 329): keys are only ever created, +// listed or changed by apps/auth's operator script, so every path under the +// prefix is refused, including any endpoint a future plugin version adds. +const API_KEY_PREFIX = '/api/auth/api-key'; +const PREFIXES = [ADMIN_PREFIX, API_KEY_PREFIX]; +// Every blocked route that is not under a prefix, from the one list createAuth() // also passes to `disabledPaths`, so the two cannot drift apart. const BLOCKED_EXACT = new Set( - BLOCKED_AUTH_PATHS.filter((path) => !ADMIN_PATHS.includes(path)).map((path) => `/api/auth${path}`) + BLOCKED_AUTH_PATHS.filter((path) => !ADMIN_PATHS.includes(path) && !API_KEY_PATHS.includes(path)).map( + (path) => `/api/auth${path}` + ) ); /** An absolute-form request target (`GET http://host/api/auth/...`). */ @@ -53,7 +61,9 @@ export function isBlockedPath(rawUrl: string): boolean { const path = canonicalPath(rawUrl); // Malformed encoding is refused rather than passed through undecided. if (path === null) return true; - return path === ADMIN_PREFIX || path.startsWith(`${ADMIN_PREFIX}/`) || BLOCKED_EXACT.has(path); + return ( + PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)) || BLOCKED_EXACT.has(path) + ); } export function blockedPaths(req: Request, res: Response, next: NextFunction): void { diff --git a/apps/auth/src/routes/health.ts b/apps/auth/src/routes/health.ts index 04c66253..3c0ad44c 100644 --- a/apps/auth/src/routes/health.ts +++ b/apps/auth/src/routes/health.ts @@ -1,6 +1,6 @@ // `GET /health`: 200 only once the auth database answers `select 1`, so a // deploy is not marked healthy while its database is unreachable. -import { pingDatabase, type Db } from '@ansari/auth'; +import { pingDatabase, redactError, type Db } from '@ansari/auth'; import type { Request, Response } from 'express'; export function health(db: Db) { @@ -11,7 +11,7 @@ export function health(db: Db) { res.status(200).json({ status: 'ok' }); } catch (err) { // Never the raw driver error: it can carry connection details. - console.error('health: auth database unreachable', err instanceof Error ? err.name : 'unknown'); + console.error('health: auth database unreachable', err instanceof Error ? redactError(err) : { name: 'unknown' }); res.status(503).json({ status: 'unavailable' }); } }; diff --git a/apps/auth/src/routes/whatsapp.ts b/apps/auth/src/routes/whatsapp.ts new file mode 100644 index 00000000..daa7115f --- /dev/null +++ b/apps/auth/src/routes/whatsapp.ts @@ -0,0 +1,223 @@ +// The WhatsApp token exchange (spec 329): the WhatsApp service, holding a +// service key, trades a BSUID for that user's JWT, the same EdDSA 15-minute +// `{sub, sid}` token apps/auth issues every client. +// +// POST /api/whatsapp/session +// Authorization: Bearer +// { "bsuid": "" } +// +// A `phone_number` field, which the WhatsApp service may still send, is +// accepted in any shape and ignored: never read, stored, logged or echoed +// (spec 329 §5, owner amendment 2). +// +// 200 { token } 400 Invalid request 401 Unauthorized (every key refusal, +// one body) 403 Forbidden (banned) 429 Too many requests (this user's +// budget, with X-Retry-After) 500 Internal error (anything else) +// +// Order: the key, then the body (parsed only after the key, with a byte +// limit), then the user, the per-user budget on the pool, and the locked +// decision (packages/auth `whatsapp.ts`). No response, log line or Sentry +// event carries the bearer, the BSUID, the phone number or the body; errors +// log `{name, code}` only. +// +// The router answers every request to its path itself (any other method is a +// bare 404) and is mounted ahead of `corsFor`, so no response from it carries +// an `Access-Control-*` header and a browser can never use it. It is outside +// Better Auth's handler path, so no router per-IP rule applies: the WhatsApp +// service calls from one egress IP, and the key's own limits and the per-user +// budget are the throttle. +import { + consumeWindow, + limits, + redactError, + schema, + serviceUsers, + whatsapp, +} from '@ansari/auth'; +import express, { Router, type NextFunction, type Request, type Response } from 'express'; + +import { + createExchangeAlerts, + WHATSAPP_SESSION_PATH, + type ExchangeAlerts, + type KeyRefusalCode, +} from '../alerts'; +import type { ServiceDeps } from '../app'; + +export { WHATSAPP_SESSION_PATH }; + +const SERVICE = 'whatsapp'; +const REQUIRED_PERMISSIONS = { whatsapp: [...serviceUsers.SERVICES.whatsapp.permissions.whatsapp] }; + +/** + * A non-empty string of at most 256 characters, with no whitespace, control + * character or format character (zero-width, bidi overrides): sanity bounds + * only (spec 329 item 9). A format character would let two BSUIDs that look + * identical name two users. + */ +const BSUID = /^[^\s\p{Cc}\p{Cf}]{1,256}$/u; + +export const exchangeLimitKey = (userId: string) => `sub:${userId}|${WHATSAPP_SESSION_PATH}`; + +export type ExchangeOptions = { + alerts?: ExchangeAlerts; + /** The clock for the relation-check cache; tests advance it. */ + now?: () => number; +}; + +type ExchangeBody = { bsuid: string }; + +const KEY_REFUSAL_CODES: readonly string[] = ['USAGE_EXCEEDED', 'RATE_LIMITED'] satisfies KeyRefusalCode[]; + +function bearerOf(req: Request): string | null { + const header = req.headers.authorization; + if (typeof header !== 'string') return null; + // The scheme is case-insensitive (RFC 7235 §2.1): a client's `bearer` must + // not get the 401 a revoked key gets. + const match = /^Bearer ([^\s]+)$/i.exec(header); + return match?.[1] ?? null; +} + +/** + * The sanity bounds (spec 329 §2, step 2); null when the body fails them. Only + * `bsuid` is read: every other field is ignored. + */ +function exchangeBody(body: unknown): ExchangeBody | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + const { bsuid } = body as Record; + if (typeof bsuid !== 'string' || !BSUID.test(bsuid)) return null; + return { bsuid }; +} + +const unauthorized = (res: Response) => res.status(401).json({ message: 'Unauthorized' }); +const invalidRequest = (res: Response) => res.status(400).json({ message: 'Invalid request' }); +const internalError = (res: Response) => res.status(500).json({ message: 'Internal error' }); + +export function whatsappRouter({ auth, db }: ServiceDeps, options: ExchangeOptions = {}): Router { + const alerts = options.alerts ?? createExchangeAlerts(); + const now = options.now ?? Date.now; + const router = Router(); + + let relationCheckedAt: number | null = null; + + /** + * The plugin reports a database failure (an outage, `auth.apikey` missing) + * as INVALID_API_KEY, the code of an unknown key. A query on `auth.apikey` + * itself (not `select 1`, which passes with the relation gone) tells them + * apart; a success is trusted for APIKEY_RELATION_CHECK_TTL. Throws on failure. + */ + async function checkApiKeyRelation(): Promise { + const at = now(); + if (relationCheckedAt !== null && at - relationCheckedAt < limits.APIKEY_RELATION_CHECK_TTL) return; + await db.select({ id: schema.apikey.id }).from(schema.apikey).limit(1); + relationCheckedAt = at; + } + + function refuse(res: Response): void { + alerts.unauthorized(); + unauthorized(res); + } + + function fail(res: Response, err: unknown): void { + console.error('whatsapp exchange failed:', err instanceof Error ? redactError(err) : { name: 'unknown' }); + if (!res.headersSent) internalError(res); + } + + /** The key: verified with `whatsapp: mint` and owned by the WhatsApp service user. */ + async function authenticate(req: Request, res: Response, next: NextFunction): Promise { + try { + const key = bearerOf(req); + if (!key) return refuse(res); + const verified = await auth.api.verifyApiKey({ body: { key, permissions: REQUIRED_PERMISSIONS } }); + if (!verified.valid || !verified.key) { + const code = verified.error?.code; + if (typeof code === 'string' && KEY_REFUSAL_CODES.includes(code)) { + alerts.keyRefused(code as KeyRefusalCode); + } + await checkApiKeyRelation(); + return refuse(res); + } + alerts.keyVerified(verified.key); + const owner = await serviceUsers.findServiceUser(db, SERVICE); + if (owner.kind !== 'one') { + alerts.serviceUserUnresolvable(owner.kind === 'many' ? owner.ids.length : 0); + return fail(res, new Error('whatsapp service user unresolvable')); + } + if (verified.key.referenceId !== owner.user.id) return refuse(res); + next(); + } catch (err) { + fail(res, err); + } + } + + /** body-parser 2.x skips a body it does not handle instead of raising, so the media type is checked here. */ + function requireJson(req: Request, res: Response, next: NextFunction): void { + if (req.is('application/json') !== 'application/json') return void invalidRequest(res); + next(); + } + + /** The parser's errors (malformed JSON, over the limit, a bad charset) → the one 400, never Express's error page. */ + // Express recognizes an error handler by its four parameters, so `_next` stays. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function parseFailure(_err: unknown, _req: Request, res: Response, _next: NextFunction): void { + invalidRequest(res); + } + + async function exchange(req: Request, res: Response): Promise { + try { + const body = exchangeBody(req.body); + if (!body) return void invalidRequest(res); + const { userId } = await whatsapp.resolveOrCreateWhatsappUser(db, body.bsuid); + + // On the pool, outside the locking transaction: a refusal inside it + // would roll the counter back, and the budget would never bind. + const rule = limits.WHATSAPP_EXCHANGE_PER_USER; + const { count, retryAfter } = await consumeWindow(db, exchangeLimitKey(userId), rule); + if (count > rule.max) { + res.status(429).set('X-Retry-After', String(retryAfter)).json({ message: 'Too many requests' }); + return; + } + + const outcome = await whatsapp.issueWhatsappToken(auth, db, { userId, ...body }); + switch (outcome.kind) { + case 'issued': + res.status(200).json({ token: outcome.token }); + return; + case 'banned': + res.status(403).json({ message: 'Forbidden' }); + return; + case 'ineligible': + alerts.ineligibleUser(userId, outcome.reason); + internalError(res); + return; + case 'user-gone': + internalError(res); + return; + default: { + // A new outcome kind fails to compile here, and answers 500 rather than hanging. + const unhandled: never = outcome; + void unhandled; + internalError(res); + return; + } + } + } catch (err) { + fail(res, err); + } + } + + router.post( + '/', + authenticate, + requireJson, + express.json({ limit: limits.EXCHANGE_BODY_LIMIT }), + parseFailure, + exchange + ); + // Every other method (OPTIONS included) and every sub-path ends here, so + // nothing falls through to corsFor. + router.use((_req: Request, res: Response) => { + res.status(404).end(); + }); + return router; +} diff --git a/apps/auth/tests/blocked-paths.test.ts b/apps/auth/tests/blocked-paths.test.ts index 1b5e4fa7..89f05354 100644 --- a/apps/auth/tests/blocked-paths.test.ts +++ b/apps/auth/tests/blocked-paths.test.ts @@ -1,11 +1,11 @@ -// The admin plugin's HTTP surface, change-email, update-user and change-password never answer -// (spec 300; scenario 28). The admin endpoints are +// The admin plugin's HTTP surface, the api-key plugin's surface (spec 329), change-email, +// update-user and change-password never answer (spec 300; scenario 28). The admin endpoints are // enumerated from the installed package, so a better-auth upgrade that adds one // is covered without editing this file. Every request carries a VALID admin // session, so a pass cannot come from a missing or non-admin session. import { request } from 'node:http'; -import { BLOCKED_AUTH_PATHS } from '@ansari/auth'; +import { API_KEY_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; import { admin } from 'better-auth/plugins'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -109,6 +109,45 @@ describe('the admin surface, from the installed package', () => { }); }); +// The api-key plugin's surface (spec 329): keys are created, listed and changed +// only by apps/auth's operator script, so every path under /api/auth/api-key is +// refused, the two server-only endpoints' would-be paths included. +describe('the api-key surface, from the installed package', () => { + const methodOf: Record = { + '/api-key/create': 'POST', + '/api-key/update': 'POST', + '/api-key/delete': 'POST', + '/api-key/get': 'GET', + '/api-key/list': 'GET', + }; + + it('blocks exactly the five routed api-key endpoints, none undefined', () => { + expect([...API_KEY_PATHS].sort()).toEqual(Object.keys(methodOf).sort()); + expect(BLOCKED_AUTH_PATHS).toEqual(expect.arrayContaining([...API_KEY_PATHS])); + expect(BLOCKED_AUTH_PATHS).not.toContain(undefined); + }); + + it('every api-key endpoint, each variant and a suffixed path get the block response', async () => { + for (const path of API_KEY_PATHS) { + const method = methodOf[path] ?? 'POST'; + for (const variant of [...variants(path), `/api/auth${path}/suffix`]) { + await expectBlocked(service.publicUrl, variant, method); + } + } + }); + + it('the server-only endpoints are blocked under the prefix too', async () => { + for (const path of ['/api-key/verify', '/api-key/delete-all-expired-api-keys', '/api-key']) { + await expectBlocked(service.publicUrl, `/api/auth${path}`, 'POST'); + } + }); + + it('a path that only starts with "api-key" is not blocked', () => { + expect(isBlockedPath('/api/auth/api-keys')).toBe(false); + expect(isBlockedPath('/api/auth/api-key-x')).toBe(false); + }); +}); + describe('change-email, update-user and change-password', () => { it.each(['/change-email', '/update-user', '/change-password'])('%s and its variants get the block response', async (path) => { for (const variant of variants(path)) { @@ -180,6 +219,8 @@ describe('canonicalPath', () => { for (const target of [ `http://localhost:${port}/api/auth/admin/list-users`, `http://localhost:${port}/api/auth/change-password`, + ...API_KEY_PATHS.map((path) => `http://localhost:${port}/api/auth${path}`), + `http://localhost:${port}/api/auth/API-KEY/list`, ]) { const status = await new Promise((resolve, reject) => { const req = request( diff --git a/apps/auth/tests/boot.test.ts b/apps/auth/tests/boot.test.ts new file mode 100644 index 00000000..e099fdb8 --- /dev/null +++ b/apps/auth/tests/boot.test.ts @@ -0,0 +1,151 @@ +// The boot assertion (spec 329): apps/auth refuses to start only on a definite +// answer that migration 0001's schema is missing, and starts as it always has +// when the database cannot answer. A stopped real Postgres is the production- +// shaped run (scripts/probes/exchange-outage-probe.ts, output in the review). +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { PGlite } from '@electric-sql/pglite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Db } from '@ansari/auth'; + +import { drizzleFor, migrate, setAuthEnv } from '../../../packages/auth/tests/helpers/auth-db'; +import { createApp, createServiceAuth } from '../src/app'; +import { assertSchema, BSUID_CONSTRAINT, SCHEMA_MISSING_MESSAGE, SCHEMA_UNCHECKED_MESSAGE } from '../src/boot'; +import { BASE } from './helpers/service'; + +let client: PGlite; +let errors: unknown[][]; + +beforeEach(async () => { + setAuthEnv(BASE); + client = await migrate(); + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args); + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await client.close().catch(() => {}); +}); + +/** Each logged meta argument is exactly `{name, code}` (or `{name}`): no message, no stack, no query. */ +function expectNameCodeOnly() { + for (const [, meta, ...rest] of errors) { + expect(rest).toEqual([]); + expect(Object.keys(meta as object).sort()).toEqual(expect.arrayContaining(['name'])); + for (const key of Object.keys(meta as object)) expect(['name', 'code']).toContain(key); + } +} + +/** + * The real database, until `down` is set; then every query fails as an + * unreachable server does (ECONNREFUSED), and recovers when it is cleared. + */ +function switchable(db: Db) { + const state = { down: false }; + const refused = () => Object.assign(new Error('connect ECONNREFUSED 10.0.0.1:5432'), { code: 'ECONNREFUSED' }); + const proxy = new Proxy(db, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) as unknown; + if (typeof value !== 'function' || !['execute', 'select', 'transaction'].includes(String(prop))) return value; + return (...args: unknown[]) => { + if (state.down) { + if (prop === 'select') return { from: () => ({ limit: () => Promise.reject(refused()) }) }; + return Promise.reject(refused()); + } + return (value as (...a: unknown[]) => unknown).apply(target, args); + }; + }, + }); + return { db: proxy, state }; +} + +describe('assertSchema', () => { + it('lets apps/auth start on the full journal, logging nothing', async () => { + expect(await assertSchema(drizzleFor(client))).toBe(true); + expect(errors).toEqual([]); + }); + + it('refuses when auth.apikey is missing (42P01), logging {name, code} only', async () => { + await client.exec('drop table auth.apikey'); + expect(await assertSchema(drizzleFor(client))).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]?.[0]).toBe(SCHEMA_MISSING_MESSAGE); + expect(errors[0]?.[1]).toMatchObject({ code: '42P01' }); + expectNameCodeOnly(); + }); + + it('refuses when unique(provider_id, account_id) is missing, logging {name, code} only', async () => { + await client.exec(`alter table auth.account drop constraint ${BSUID_CONSTRAINT}`); + expect(await assertSchema(drizzleFor(client))).toBe(false); + expect(errors).toEqual([[SCHEMA_MISSING_MESSAGE, { name: 'MissingConstraint', code: BSUID_CONSTRAINT }]]); + }); + + it.each([ + [ + 'the name on another auth table', + `create table auth.decoy (provider_id text, account_id text, constraint ${BSUID_CONSTRAINT} unique (provider_id, account_id))`, + ], + [ + 'the name on auth.account over other columns', + `alter table auth.account add constraint ${BSUID_CONSTRAINT} unique (account_id, user_id)`, + ], + [ + 'the name on auth.account over the columns in the other order', + `alter table auth.account add constraint ${BSUID_CONSTRAINT} unique (account_id, provider_id)`, + ], + ])('refuses when the real constraint is gone and only %s remains', async (_name, spoof) => { + await client.exec(`alter table auth.account drop constraint ${BSUID_CONSTRAINT}`); + await client.exec(spoof); + expect(await assertSchema(drizzleFor(client))).toBe(false); + expect(errors).toEqual([[SCHEMA_MISSING_MESSAGE, { name: 'MissingConstraint', code: BSUID_CONSTRAINT }]]); + }); + + it('starts when the database cannot answer, logging {name, code} only', async () => { + const { db, state } = switchable(drizzleFor(client)); + state.down = true; + expect(await assertSchema(db)).toBe(true); + expect(errors).toEqual([[SCHEMA_UNCHECKED_MESSAGE, { name: 'Error', code: 'ECONNREFUSED' }]]); + }); + + it('starts when the relation answers but the catalog lookup fails', async () => { + const real = drizzleFor(client); + const db = new Proxy(real, { + get(target, prop, receiver) { + if (prop === 'execute') return () => Promise.reject(Object.assign(new Error('timeout'), { code: '57014' })); + return Reflect.get(target, prop, receiver) as unknown; + }, + }); + expect(await assertSchema(db)).toBe(true); + expect(errors).toEqual([[SCHEMA_UNCHECKED_MESSAGE, { name: 'Error', code: '57014' }]]); + }); +}); + +describe('the database unreachable at boot', () => { + it('apps/auth starts and listens, /health reports down, then recovers without a restart', async () => { + const { db, state } = switchable(drizzleFor(client)); + state.down = true; + expect(await assertSchema(db)).toBe(true); + + const app = createApp({ auth: createServiceAuth(db), db }); + const server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/health`; + try { + expect((await fetch(url)).status).toBe(503); + // {name, code} only, as boot and the exchange log it: no message (it names the host). + expect(errors.at(-1)).toEqual(['health: auth database unreachable', { name: 'Error', code: 'ECONNREFUSED' }]); + state.down = false; + const res = await fetch(url); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + } finally { + await new Promise((r) => server.close(r)); + } + }); +}); diff --git a/apps/auth/tests/cutover/auth-journal.test.ts b/apps/auth/tests/cutover/auth-journal.test.ts new file mode 100644 index 00000000..66047866 --- /dev/null +++ b/apps/auth/tests/cutover/auth-journal.test.ts @@ -0,0 +1,74 @@ +// auth-journal.mjs (main architect, 2026-10-28): the cutover applies and checks the WHOLE auth +// journal, in _journal.json order, with a per-migration check derived from the SQL, so a later +// migration needs no runbook edit. The check runs here against pglite built from the journal. +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { journal, migrate } from '../../../../packages/auth/tests/helpers/auth-db'; +// @ts-expect-error: a plain .mjs operator script, no type declarations +import { checkSql, migrations, objectChecks } from '../../scripts/cutover/auth-journal.mjs'; + +type Migration = { tag: string; file: string }; +const all = () => migrations() as Migration[]; + +describe('auth-journal.mjs', () => { + it('lists every journal entry, in idx order, each file on disk', () => { + const tags = [...journal].sort((a, b) => a.idx - b.idx).map((e) => e.tag); + expect(all().map((m) => m.tag)).toEqual(tags); + expect(tags.length).toBeGreaterThanOrEqual(2); + }); + + it('derives a check for every object each migration creates', () => { + const [m0000, m0001] = all(); + const created = (file: string) => objectChecks(readFileSync(file, 'utf8')) as string[]; + // 0000: the schema, 7 tables... counted from the SQL, never hardcoded in a runbook. + expect(created(m0000!.file).length).toBe(readFileSync(m0000!.file, 'utf8').split('--> statement-breakpoint').filter((s) => s.trim()).length); + const c0001 = created(m0001!.file).join('\n'); + expect(c0001).toContain(`to_regclass('"auth"."apikey"')`); + expect(c0001).not.toContain('phone_number'); + expect(c0001).toContain("c.conname = 'account_provider_id_account_id_unique'"); + }); + + // No current migration adds a column, so this keeps the ADD COLUMN branch covered for the next one. + it('derives a column check from an ADD COLUMN statement', () => { + const checks = objectChecks('ALTER TABLE "auth"."users" ADD COLUMN "x" text;', '0002_add') as string[]; + expect(checks).toHaveLength(1); + expect(checks[0]).toContain("table_schema = 'auth' and table_name = 'users' and column_name = 'x'"); + }); + + it('refuses a statement kind it cannot check, naming the migration', () => { + expect(() => objectChecks('ALTER TABLE "auth"."users" DROP COLUMN "x";', '0002_drop')).toThrow( + /0002_drop: a statement the journal check does not know/ + ); + }); + + it('the check passes on the whole journal, one |t per migration in order', async () => { + const client = await migrate(); + try { + const results = await client.exec(checkSql() as string); + const rows = results.flatMap((r) => r.rows.map((row) => Object.values(row as object).map(String).join('|'))); + expect(rows).toEqual(all().map((m) => `${m.tag}|true`)); + } finally { + await client.close(); + } + }); + + it('the check fails, naming the migration, on a database migrated only through 0000', async () => { + const client = await migrate('0000_auth_schema'); + try { + await expect(client.exec(checkSql() as string)).rejects.toThrow(/auth journal check: not applied: 0001_api_key/); + } finally { + await client.close(); + } + }); + + it('a check that only knew 0000 would pass there: the journal-derived check is what catches 0001', async () => { + const client = await migrate('0000_auth_schema'); + try { + await expect(client.exec(checkSql(all().slice(0, 1)) as string)).resolves.toBeDefined(); + } finally { + await client.close(); + } + }); +}); diff --git a/apps/auth/tests/cutover/copy.test.ts b/apps/auth/tests/cutover/copy.test.ts index c29820d7..aff32f98 100644 --- a/apps/auth/tests/cutover/copy.test.ts +++ b/apps/auth/tests/cutover/copy.test.ts @@ -28,9 +28,11 @@ import { isForwarderHost, makeHashes, refusal, - urlHost, } from '../../scripts/cutover/rehearsal-anomalies'; +import { urlHost } from '../../scripts/url-host'; import { runPsqlFile } from './psql'; +// @ts-expect-error: a plain .mjs operator script, no type declarations +import { baselineSql, migrations } from '../../scripts/cutover/auth-journal.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const SCRIPTS = resolve(here, '../../scripts/cutover'); @@ -129,11 +131,17 @@ function preflightQuery(heading: string): string { let app: PGlite; let auth: PGlite; +// This hook's own deadline, above the config's 30 s (#292's value). Two +// pglite cold starts (the app and auth journals) took 27-53 s when the whole +// monorepo's tests ran at once on a shared machine (measured for spec 329; the +// #290/#292/#301 class). 100 s is about twice the observed maximum. +const SETUP_DEADLINE_MS = 100_000; + beforeEach(async () => { bcryptHash ??= await bcrypt.hash(PASSWORD, 4); app = await appDatabase(); auth = await migrate(); -}); +}, SETUP_DEADLINE_MS); afterEach(async () => { await app?.close(); @@ -393,13 +401,22 @@ describe('the copy and the audits', () => { }); }); +/** The dry run's baseline file: what `auth-journal.mjs sql` prints (copy.sh hands psql a file descriptor). */ +function baselineFile(sql: string = baselineSql() as string): string { + const dir = mkdtempSync(resolve(tmpdir(), 'auth-journal-')); + const file = resolve(dir, 'baseline.sql'); + writeFileSync(file, sql); + return file; +} + /** 01 | 02 as copy.sh --dry-run runs them: the auth database left as found. */ -async function dryCopy(app: PGlite, auth: PGlite, options: { baseline?: boolean } = {}) { +async function dryCopy(app: PGlite, auth: PGlite, options: { baseline?: boolean | string } = {}) { const expected_rows = await expectedRows(app); const exported = await runPsqlFile(app, script('01-export-app.sql')); + const baseline = options.baseline === true ? baselineFile() : options.baseline; return runPsqlFile(auth, script('02-import-auth.sql'), { stdin: exported.stdout.join(''), - vars: { expected_rows, dry_run: '1', ...(options.baseline ? { dry_run_baseline: '1' } : {}) }, + vars: { expected_rows, dry_run: '1', ...(baseline ? { dry_run_baseline: baseline } : {}) }, }); } @@ -441,6 +458,35 @@ describe('copy.sh --dry-run: the same copy, rolled back', () => { } }); + it('the baseline is the whole journal: a baseline missing a later migration fails its assertion, naming it', async () => { + await seed(app, legacy()); + const all = migrations() as { tag: string; file: string }[]; + expect(all.map((m) => m.tag)).toEqual(['0000_auth_schema', '0001_api_key']); + // Only 0000's SQL, followed by the full journal's assertion (from the real baseline). + const full = baselineSql() as string; + const assertion = full.slice(full.indexOf('do $check$')); + const only0000 = `${readFileSync(all[0]!.file, 'utf8')}\n${assertion}`; + const bare = emptyDatabase(); + try { + await expect(dryCopy(app, bare, { baseline: baselineFile(only0000) })).rejects.toThrow( + /auth journal check: not applied: 0001_api_key/ + ); + expect(await authSchemas(bare)).toBe(0); + } finally { + await bare.close(); + } + }); + + it('the baseline carries every journal file in order, then the assertion', () => { + const sql = baselineSql() as string; + const at = (needle: string) => sql.indexOf(needle); + expect(at('-- 0000_auth_schema')).toBeGreaterThanOrEqual(0); + expect(at('-- 0001_api_key')).toBeGreaterThan(at('-- 0000_auth_schema')); + expect(at('CREATE TABLE "auth"."apikey"')).toBeGreaterThan(at('-- 0001_api_key')); + expect(at('do $check$')).toBeGreaterThan(at('CREATE TABLE "auth"."apikey"')); + expect(sql).not.toContain('phone_number'); + }); + it('the guards still fire, and a refused dry run leaves the database as found', async () => { await seed(app, legacy()); await app.exec(`UPDATE users SET updated_at = NULL WHERE id = '${id(2)}'`); diff --git a/apps/auth/tests/cutover/psql.ts b/apps/auth/tests/cutover/psql.ts index 47c2602c..772de21c 100644 --- a/apps/auth/tests/cutover/psql.ts +++ b/apps/auth/tests/cutover/psql.ts @@ -6,8 +6,9 @@ // tuples-only, unaligned, '|'-separated), `\if :{?NAME}` / `\if :NAME` / // `\else` / `\endif`, `\copy (…) TO pstdout` and `\copy table FROM pstdin` // (through pglite's /dev/blob device), `\ir FILE` (inlined, relative to the -// including file, as psql's \include_relative), and `:'NAME'` / `:NAME` -// interpolation. +// including file, as psql's \include_relative), `\i FILE` or `\i :NAME` +// (inlined when reached, the path interpolated, as psql's \include), and +// `:'NAME'` / `:NAME` interpolation. // ON_ERROR_STOP is always on: the first error throws. psql would then end the // session, rolling back an open transaction and dropping TEMP tables; the // harness does the same to the pglite instance (`ROLLBACK`, `DISCARD TEMP`), @@ -47,6 +48,12 @@ function rowsOf(results: Results[]): string[] { ); } +/** An included file with no meta-commands, run as SQL in the current session. */ +async function runPsqlLines(client: PGlite, lines: string[]): Promise { + if (lines.some((l) => l.trim().startsWith('\\'))) throw new Error('psql harness: meta-commands in an \\i file are not supported'); + return rowsOf(await client.exec(lines.join('\n'))); +} + export async function runPsqlFile( client: PGlite, file: string, @@ -96,6 +103,11 @@ export async function runPsqlFile( branches.pop(); } else if (!active()) { continue; + } else if (command === '\\i') { + // Run the included file's statements in this session, as psql does. + const included = interpolate(arg, vars); + const sub = await runPsqlLines(client, readFileSync(included, 'utf8').split('\n')); + out.stdout.push(...sub); } else if (command === '\\set') { const [name = '', ...value] = rest; vars[name] = value.join(' '); diff --git a/apps/auth/tests/grant-admin.test.ts b/apps/auth/tests/grant-admin.test.ts index 5a6304d6..4c2d84d2 100644 --- a/apps/auth/tests/grant-admin.test.ts +++ b/apps/auth/tests/grant-admin.test.ts @@ -11,6 +11,7 @@ import { RECENT_ACCOUNT_MS, runGrantAdmin, scriptError, + NoreplyAddressError, SystemAddressError, } from '../scripts/grant-admin'; import { signUpTokens, startService, type Service } from './helpers/service'; @@ -157,6 +158,33 @@ describe('grant-admin.ts', () => { } }); + it('refuses a noreply-domain address (service and WhatsApp users), before any lookup (spec 329)', async () => { + for (const command of [ + { kind: 'show', email: 'service-whatsapp@noreply.askansari.ai' }, + { kind: 'grant', email: ' WhatsApp-x@NoReply.AskAnsari.AI ', confirmId: 'x' }, + ] as const) { + await expect(runGrantAdmin(service.db, command)).rejects.toBeInstanceOf(NoreplyAddressError); + } + }); + + // Demotion only lowers a role, so it is not refused: it is the runbook's repair + // for a WhatsApp user found holding `admin` (eligibility reason `role`). + it('revokes a noreply user seeded as admin, and still refuses to grant it (spec 329)', async () => { + const email = 'whatsapp-seeded-admin@noreply.askansari.ai'; + const { rows } = await service.client.query<{ id: string }>( + `insert into auth.users (name, email, registered_via, role) values ('x', $1, 'whatsapp', 'admin') returning id`, + [email] + ); + await expect(runGrantAdmin(service.db, { kind: 'grant', email, confirmId: rows[0]!.id })).rejects.toBeInstanceOf( + NoreplyAddressError + ); + await runGrantAdmin(service.db, { kind: 'revoke', email }); + const after = await service.client.query<{ role: string | null }>('select role from auth.users where id = $1', [ + rows[0]!.id, + ]); + expect(after.rows[0]?.role).toBe('user'); + }); + it('revokes back to role user', async () => { const email = nextEmail('revoke'); await signUpTokens(service, email); @@ -205,6 +233,7 @@ describe('grant-admin.ts', () => { expect(scriptError(new users.UserNotFoundError())).toBe('No auth user has this email'); expect(scriptError(new ConfirmMismatchError())).toMatch(/Nothing was granted/); expect(scriptError(new SystemAddressError())).toMatch(/system-domain/); + expect(scriptError(new NoreplyAddressError())).toMatch(/noreply-domain/); }); }); diff --git a/apps/auth/tests/helpers/service.ts b/apps/auth/tests/helpers/service.ts index 37e94ca1..81b3fbd0 100644 --- a/apps/auth/tests/helpers/service.ts +++ b/apps/auth/tests/helpers/service.ts @@ -15,6 +15,7 @@ import { } from '../../../../packages/auth/tests/helpers/auth-db'; import type { FailedSignInAlert, ListWalkAlert } from '../../src/alerts'; import { createApp, createServiceAuth } from '../../src/app'; +import type { ExchangeOptions } from '../../src/routes/whatsapp'; export { TEST_ADMIN_EMAIL } from '../../../../packages/auth/tests/helpers/auth-db'; @@ -37,6 +38,8 @@ export type ServiceOptions = { failedSignInAlert?: (alert: FailedSignInAlert) => void; /** Replaces the capturing mailer (which records into `mail`). */ mailer?: Mailer; + /** The WhatsApp exchange's alerts and clock. */ + exchange?: ExchangeOptions; }; function listen(app: { listen: (port: number, cb: () => void) => Server }): Promise { @@ -65,7 +68,7 @@ export async function startService(options: ServiceOptions = {}): Promise PHONE.test(line)); +} + +function tsFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return tsFiles(path); + return name.endsWith('.ts') ? [path] : []; + }); +} + +describe('no code reads a phone number', () => { + it('reports 0 hits over packages/auth/src and apps/auth/src (comments excluded)', () => { + const files = SCANNED.flatMap((dir) => tsFiles(join(REPO, dir))); + expect(files.length).toBeGreaterThan(10); + const hits = files.flatMap((file) => + phoneHits(readFileSync(file, 'utf8')).map((line) => `${relative(REPO, file)}: ${line.trim()}`) + ); + process.stdout.write(`no-phone scan: ${hits.length} hit(s) in ${files.length} files\n`); + expect(hits).toEqual([]); + }); + + it('catches a planted read, and ignores the same words in a comment (negative tests)', () => { + expect(phoneHits(" const { bsuid, phone_number: phone } = body as Record;")).toHaveLength(1); + expect(phoneHits(' phoneNumber: text(\'phone_number\'),')).toHaveLength(1); + expect(phoneHits('// A `phone_number` field is accepted and ignored.')).toEqual([]); + expect(phoneHits('/**\n * never the BSUID or a phone number.\n */')).toEqual([]); + expect(phoneHits("const url = 'https://example.com/phone'; // a string is code")).toHaveLength(1); + }); +}); diff --git a/apps/auth/tests/public-face.test.ts b/apps/auth/tests/public-face.test.ts index 1d5f11cc..db1470da 100644 --- a/apps/auth/tests/public-face.test.ts +++ b/apps/auth/tests/public-face.test.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { limits } from '@ansari/auth'; + import { TEST_LAYER_ORIGIN } from '../../../packages/auth/tests/helpers/auth-db'; import { BASE, @@ -212,3 +214,31 @@ describe('health', () => { } }); }); + +describe('route inventory (spec 329)', () => { + // Every surface the public listener serves, each proved mounted by the + // status an anonymous request gets. A new surface is added here on purpose. + it.each([ + ['GET', '/health', 200], + ['GET', '/__diag/client-ip', 200], + ['GET', '/api/auth/jwks', 200], + ['GET', '/api/account/me', 401], + ['POST', '/api/account/revoke-sessions', 401], + ['DELETE', '/api/account', 401], + ['POST', '/api/whatsapp/session', 401], + ['GET', '/api/whatsapp/session', 404], + ['GET', '/not-a-route', 404], + ])('%s %s → %i', async (method, path, status) => { + expect((await send(service.publicUrl, path, { method })).status).toBe(status); + }); + + it('ROUTER_RULES are unchanged: every rule false, so no per-IP rule applies to the layer or the exchange', () => { + expect(limits.ROUTER_RULES).toEqual({ + '/sign-in/email': false, + '/sign-up/email': false, + '/request-password-reset': false, + '/reset-password': false, + '/token': false, + }); + }); +}); diff --git a/apps/auth/tests/reset.test.ts b/apps/auth/tests/reset.test.ts index 00ca4135..24dce569 100644 --- a/apps/auth/tests/reset.test.ts +++ b/apps/auth/tests/reset.test.ts @@ -2,6 +2,8 @@ // plan phase 5): the emailed link has the legacy shape, an unknown email gets // the identical answer and no mail, a reset kills every session, and the // strength check runs before the password changes. +import { randomUUID } from 'node:crypto'; + import { RESET_EMAIL_FROM, RESET_EMAIL_SUBJECT, resetEmailHtml, resetLink } from '@ansari/auth'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -69,6 +71,23 @@ describe('the reset email', () => { expect(service.mail.slice(before).map((m) => m.to)).toEqual([known]); }); + it('an existing noreply user gets the response an unknown address gets, and no email (spec 329)', async () => { + const id = randomUUID(); + const noreply = `whatsapp-${id}@noreply.askansari.ai`; + await service.client.query(`INSERT INTO auth.users (id, name, email) VALUES ($1, 'WhatsApp user', $2)`, [ + id, + noreply, + ]); + const unknown = nextEmail('unknown-noreply-pair'); + const before = service.mail.length; + + const a = await requestReset(noreply); + const b = await requestReset(unknown); + expect(a.status).toBe(b.status); + expect(await a.json()).toEqual(await b.json()); + expect(service.mail.length).toBe(before); + }); + it('a system address receives no email', async () => { const before = service.mail.length; expect((await requestReset('ai-skill@system.ansari.chat')).status).toBe(200); diff --git a/apps/auth/tests/secrets.test.ts b/apps/auth/tests/secrets.test.ts index a4430b58..02e41495 100644 --- a/apps/auth/tests/secrets.test.ts +++ b/apps/auth/tests/secrets.test.ts @@ -64,6 +64,12 @@ async function keep(res: Promise): Promise { let service: Service; let failingMail: Service; +// This hook's own deadline, above the config's 30 s (#292's value). Two +// services, each a pglite cold start plus the auth journal, took 27-53 s when +// the whole monorepo's tests ran at once on a shared machine (measured for +// spec 329; the #290/#292/#301 class). 100 s is about twice the observed max. +const SETUP_DEADLINE_MS = 100_000; + beforeAll(async () => { service = await startService(); failingMail = await startService({ @@ -72,7 +78,7 @@ beforeAll(async () => { }, }); capture(); -}); +}, SETUP_DEADLINE_MS); afterAll(async () => { for (const undo of restore.reverse()) undo(); diff --git a/apps/auth/tests/sentry-request-data.test.ts b/apps/auth/tests/sentry-request-data.test.ts new file mode 100644 index 00000000..1d4aebf3 --- /dev/null +++ b/apps/auth/tests/sentry-request-data.test.ts @@ -0,0 +1,144 @@ +// Issue #333: Sentry's request-data integration attaches the in-flight +// request's body, headers, cookies and query string to an alert event raised +// while that request is handled, here the sign-in that crosses a threshold. +// Production-shaped: default integrations on, only the transport replaced. Its +// own file: the Console integration patches `console` process-wide. +// +// Assertions name which fixture leaked, never its value. `console.warn` is a +// no-op here (installed before the first init, so the Console integration +// still records it as a breadcrumb): the probe's log line carries a fixture. +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, expect, it, vi } from 'vitest'; + +import { limits } from '@ansari/auth'; + +import { + FAILED_SIGN_IN_MESSAGE, + LIST_WALK_MESSAGE, + SERVICE_SENTRY_OPTIONS, + sendFailedSignInAlert, + sendListWalkAlert, +} from '../src/alerts'; +import { send, signUp, startService } from './helpers/service'; +import type { FailedSignInAlert } from '../src/alerts'; + +const FIXTURES = { + password: 'ProbePasswordMarker-123', + emailInBody: 'probe-mailbox-marker', + bearer: 'SESSIONTOKENMARKER', + cookie: 'COOKIEVALUEMARKER', + query: 'QUERYVALUEMARKER', + logLine: 'LOGLINEMARKER', +}; + +type CapturedEvent = { message?: string; request?: { headers?: Record } & Record }; + +beforeAll(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterAll(async () => { + await Sentry.close(); + vi.restoreAllMocks(); +}); + +/** + * A log line that names a user, written in the request before the alert, as + * the WhatsApp exchange's ineligible-user line is (#331): only the breadcrumb + * options keep it out of the event. + */ +function failedSignInAlertAfterALogLine(alert: FailedSignInAlert): void { + console.warn('probe: a log line that names a user', FIXTURES.logLine); + sendFailedSignInAlert(alert); +} + +/** + * Walk LIST_WALK_ALERT.distinctEmails emails to their per-email lock: that + * crosses SIGN_IN_FAILED_ALERT on the way and the list-walk threshold at the + * end, so each alert fires once, inside a sign-in request. + */ +async function captureAlertEnvelopes(options: Sentry.NodeOptions) { + const envelopes: unknown[] = []; + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + environment: 'test', + ...options, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => true, + }), + }); + const service = await startService({ alert: sendListWalkAlert, failedSignInAlert: failedSignInAlertAfterALogLine }); + try { + await signUp(service, `${FIXTURES.emailInBody}@example.com`); + for (let i = 0; i < limits.LIST_WALK_ALERT.distinctEmails; i++) { + for (let n = 0; n < limits.SIGN_IN_PER_EMAIL.max; n++) { + await send(service.publicUrl, `/api/auth/sign-in/email?probe=${FIXTURES.query}`, { + body: { email: `${FIXTURES.emailInBody}-${i}@example.com`, password: FIXTURES.password }, + headers: { + authorization: `Bearer ${FIXTURES.bearer}`, + cookie: `probe=${FIXTURES.cookie}`, + }, + }); + } + } + await Sentry.flush(2000); + } finally { + await service.close(); + await Sentry.close(); + } + const events = envelopes + .flatMap((e) => (e as [unknown, [{ type: string }, CapturedEvent][]])[1]) + .filter(([header]) => header.type === 'event') + .map(([, event]) => event); + const text = JSON.stringify(envelopes); + const leaked = Object.entries(FIXTURES) + .filter(([, value]) => text.includes(value)) + .map(([name]) => name); + return { events, leaked }; +} + +it('a failed-sign-in alert event carries no password, no email from the body and no Authorization header', async () => { + const { events, leaked } = await captureAlertEnvelopes(SERVICE_SENTRY_OPTIONS); + expect(events.map((e) => e.message).sort()).toEqual([FAILED_SIGN_IN_MESSAGE, LIST_WALK_MESSAGE].sort()); + for (const event of events) { + expect(Object.keys(event.request ?? {}).sort()).toEqual(['headers', 'method', 'url']); + expect(Object.keys(event.request?.headers ?? {}).sort()).toEqual(['content-type', 'host', 'user-agent']); + expect(event.request?.method).toBe('POST'); + expect(event.request?.url).toMatch(/\/api\/auth\/sign-in\/email$/); + } + expect(leaked).toEqual([]); +}, 120_000); + +it('negative control: without the scrub, the same alert events carry the body, headers, cookies and query', async () => { + const { events, leaked } = await captureAlertEnvelopes({ ...SERVICE_SENTRY_OPTIONS, beforeSend: undefined }); + expect(events).toHaveLength(2); + for (const event of events) { + expect(Object.keys(event.request ?? {})).toEqual(expect.arrayContaining(['data', 'headers'])); + } + // The log line is a breadcrumb, which the breadcrumb options still drop. + expect(leaked.sort()).toEqual(Object.keys(FIXTURES).filter((name) => name !== 'logLine').sort()); +}, 120_000); + +it('negative control: with breadcrumbs on, the alert event carries the log line', async () => { + const breadcrumbsOn = { ...SERVICE_SENTRY_OPTIONS, maxBreadcrumbs: undefined, beforeBreadcrumb: undefined }; + const { leaked } = await captureAlertEnvelopes(breadcrumbsOn); + expect(leaked).toContain('logLine'); +}, 120_000); + +it("leaves Sentry's data collection at its private defaults", async () => { + Sentry.init({ dsn: 'https://public@o0.ingest.sentry.io/0', environment: 'test', ...SERVICE_SENTRY_OPTIONS }); + try { + expect(Sentry.getClient()?.getDataCollectionOptions()).toMatchObject({ + userInfo: false, + httpBodies: [], + genAI: { inputs: false, outputs: false }, + databaseQueryData: false, + }); + } finally { + await Sentry.close(); + } +}); diff --git a/apps/auth/tests/service-keys-no-dsn.test.ts b/apps/auth/tests/service-keys-no-dsn.test.ts new file mode 100644 index 00000000..6f830aff --- /dev/null +++ b/apps/auth/tests/service-keys-no-dsn.test.ts @@ -0,0 +1,60 @@ +// The service-keys script with no SENTRY_DSN (security PR gate, pass 1, R3). +// Its own file: Sentry is the real module and never initialised here, so the +// test shows the unrecorded path is taken on purpose, not because an +// uninitialised Sentry's flush happens to answer false. +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, expect, it } from 'vitest'; + +import { EXIT_UNRECORDED, scriptRecorder, serviceKeys, unrecordedRecorder } from '../scripts/service-keys'; +import { startService, type Service } from './helpers/service'; + +let service: Service; + +beforeAll(async () => { + service = await startService(); +}); +afterAll(async () => { + await service.close(); +}); + +async function run(argv: string[], recorder: ReturnType) { + const lines: string[] = []; + const result = await serviceKeys(argv, { + databaseUrl: 'postgres://db.example:6543/auth', + connect: () => ({ db: service.db, auth: service.auth }), + recorder, + print: (line) => lines.push(line), + }); + return { ...result, text: lines.join('\n') }; +} + +it('no DSN: nothing is captured or flushed; the write stands, the banner names the key id, exit 3', async () => { + expect(Sentry.getClient()).toBeUndefined(); + const recorder = scriptRecorder(undefined); + expect(recorder).toBe(unrecordedRecorder); + expect(Sentry.getClient()).toBeUndefined(); + + const created = await run(['create-service', 'whatsapp', '--confirm', 'whatsapp'], recorder); + expect(created.exitCode, created.text).toBe(EXIT_UNRECORDED); + const { rows } = await service.client.query<{ id: string }>( + `select user_id as id from auth.account where provider_id = 'service' and account_id = 'whatsapp'` + ); + const userId = rows[0]!.id; + + const keyed = await run( + ['create-key', 'whatsapp', '--rate-max', '60', '--rate-window', '60', '--daily', '3000', '--confirm', userId], + recorder + ); + expect(keyed.exitCode, keyed.text).toBe(EXIT_UNRECORDED); + const keyId = /Created key ([0-9a-f-]{36})/.exec(keyed.text)![1]!; + const banner = keyed.text.slice(keyed.text.indexOf('THE SENTRY RECORD')); + expect(banner).toContain('SENTRY_DSN is not set: no record was attempted.'); + expect(banner).toContain(`key ${keyId}`); + const { rows: keys } = await service.client.query('select id from auth.apikey where id = $1', [keyId]); + expect(keys).toHaveLength(1); + + // Still never initialised: with no client a capture is unobservable, so the + // path is shown by the recorder chosen and the reason in the banner, which + // the old implicit path (an uninitialised flush answering false) never printed. + expect(Sentry.getClient()).toBeUndefined(); +}); diff --git a/apps/auth/tests/service-keys-sentry.test.ts b/apps/auth/tests/service-keys-sentry.test.ts new file mode 100644 index 00000000..a0ac7c73 --- /dev/null +++ b/apps/auth/tests/service-keys-sentry.test.ts @@ -0,0 +1,100 @@ +// The service-keys script under Sentry's PRODUCTION defaults (spec 329: no +// Sentry event carries key material). Default integrations stay on, so the +// Console integration turns console lines into breadcrumbs, exactly as in the +// script's own Sentry.init; only the transport is a spy. Its own file: the +// Console integration patches `console` for the whole process. +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { serviceUsers } from '@ansari/auth'; + +import { + KEY_EVENT_MESSAGE, + printLine, + SCRIPT_SENTRY_OPTIONS, + sentryRecorder, + serviceKeys, +} from '../scripts/service-keys'; +import { startService, type Service } from './helpers/service'; + +const DB_URL = 'postgres://ops:pw@db.example:6543/auth'; +const LIMIT_FLAGS = ['--rate-max', '60', '--rate-window', '60', '--daily', '3000']; + +const envelopes: unknown[] = []; +let service: Service; +let userId: string; + +beforeAll(async () => { + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + ...SCRIPT_SENTRY_OPTIONS, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => true, + }), + }); + service = await startService(); + await serviceUsers.createServiceUser(service.db, 'whatsapp'); + const found = await serviceUsers.findServiceUser(service.db, 'whatsapp'); + if (found.kind !== 'one') throw new Error('no service user'); + userId = found.user.id; +}); +afterAll(async () => { + await Sentry.close(); + await service.close(); +}); +beforeEach(() => { + envelopes.length = 0; +}); + +async function createKey(print: (line: string) => void) { + const result = await serviceKeys(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId], { + databaseUrl: DB_URL, + connect: () => ({ db: service.db, auth: service.auth }), + recorder: sentryRecorder, + print, + }); + expect(result.exitCode).toBe(0); + const key = result.lines.find((l) => /^ansari_wa_[A-Za-z]{64}$/.test(l)); + if (!key) throw new Error('no key in the output'); + return key; +} + +function expectNoKey(key: string) { + const serialized = JSON.stringify(envelopes); + expect(occurrences(serialized, KEY_EVENT_MESSAGE)).toBe(1); + expect(serialized).not.toContain(key); + expect(serialized).not.toContain(key.slice(3, 20)); +} + +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +describe('service-keys under production Sentry defaults', () => { + it("the script's printer: the key reaches stdout once and no Sentry event", async () => { + const written: string[] = []; + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + let key: string; + try { + key = await createKey(printLine); + } finally { + write.mockRestore(); + } + expect(occurrences(written.join(''), key)).toBe(1); + expectNoKey(key); + }); + + it('even printed through console.log, the key is not a breadcrumb (breadcrumbs are off)', async () => { + const log = vi.spyOn(console, 'log'); + try { + expectNoKey(await createKey((line) => console.log(line))); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/apps/auth/tests/service-keys.test.ts b/apps/auth/tests/service-keys.test.ts new file mode 100644 index 00000000..9b95df0a --- /dev/null +++ b/apps/auth/tests/service-keys.test.ts @@ -0,0 +1,831 @@ +// The service-keys operator script (spec 329, plan phase 4): service users, +// and keys issued, listed, rotated, changed and revoked in grant-admin.ts's +// posture, against the auth database on pglite. Script functions are driven +// directly; Sentry through a spy transport. +import { createHash } from 'node:crypto'; + +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { limits, serviceUsers } from '@ansari/auth'; + +import { + checkKeyLimits, + EXIT_NOTHING_WRITTEN, + EXIT_UNRECORDED, + KEY_EVENT_MESSAGE, + parseArgs, + RefusedError, + sentryRecorder, + serviceKeys, + type KeyEvent, + type Recorder, +} from '../scripts/service-keys'; +import { urlHostPort } from '../scripts/url-host'; +import { startService, type Service } from './helpers/service'; + +/** The plan's URL: a user, a password and a query string, none of which may be printed. */ +const DB_URL = 'postgres://user:secret@db.example:6543/auth?sslmode=require'; +/** + * The same shape with distinctive userinfo: the output says "user" in its own + * words ("Service user for …"), so this one shows the URL's user is not echoed. + */ +const DB_URL_DISTINCT = 'postgres://opsname-zq:pw-zq-9@db.example:6543/authdb-zq?sslmode=verify-full&application_name=zq'; + +const INITIAL = limits.WHATSAPP_KEY_INITIAL; +const LIMIT_FLAGS = ['--rate-max', '60', '--rate-window', '60', '--daily', '3000']; +const WA_KEY = /ansari_wa_[A-Za-z]{64}/g; + +let service: Service; + +beforeAll(async () => { + service = await startService(); +}); +afterAll(async () => { + await service.close(); +}); + +beforeEach(async () => { + // Service users are one per name: each test starts without any (keys and + // the `service` rows go with the cascade). + await service.client.query(`delete from auth.users where registered_via = 'service'`); +}); + +/** A recorder that keeps events in memory and flushes as told. */ +function memoryRecorder(flushed = true): Recorder & { events: KeyEvent[]; flushes: number } { + return { + events: [], + flushes: 0, + capture(event) { + this.events.push(event); + }, + async flush() { + this.flushes += 1; + return flushed; + }, + }; +} + +type RunOptions = { url?: string | undefined; recorder?: Recorder; flushTimeoutMs?: number }; + +async function run(argv: string[], opts: RunOptions = {}) { + const connect = vi.fn(() => ({ db: service.db, auth: service.auth })); + const print = vi.fn<(line: string) => void>(); + const result = await serviceKeys(argv, { + databaseUrl: 'url' in opts ? opts.url : DB_URL, + connect, + recorder: opts.recorder ?? memoryRecorder(), + print, + flushTimeoutMs: opts.flushTimeoutMs, + }); + return { ...result, text: result.lines.join('\n'), connect, print }; +} + +async function serviceUserId(name = 'whatsapp'): Promise { + const found = await serviceUsers.findServiceUser(service.db, name as serviceUsers.ServiceName); + if (found.kind !== 'one') throw new Error(`expected one service user, got ${found.kind}`); + return found.user.id; +} + +async function createService(name = 'whatsapp'): Promise { + const { exitCode } = await run(['create-service', name, '--confirm', name]); + expect(exitCode).toBe(0); + return serviceUserId(name); +} + +/** create-key (or rotate) with --confirm; returns the key's id and the raw key from the output. */ +async function createKey(kind: 'create-key' | 'rotate' = 'create-key', flags = LIMIT_FLAGS, name = 'whatsapp') { + const userId = await serviceUserId(name); + const result = await run([kind, name, ...flags, '--confirm', userId]); + expect(result.exitCode, result.text).toBe(0); + const prefix = serviceUsers.SERVICES[name as serviceUsers.ServiceName].prefix; + const key = result.lines.find((l) => l.startsWith(prefix) && l.length > prefix.length + 60); + const id = /Created key ([0-9a-f-]{36})/.exec(result.text)?.[1]; + if (!key || !id) throw new Error('no key in the output'); + return { ...result, key, id, userId }; +} + +async function keyRow(id: string) { + const { rows } = await service.client.query>('select * from auth.apikey where id = $1', [id]); + return rows[0]; +} + +async function rowCounts() { + const { rows } = await service.client.query<{ users: number; accounts: number; keys: number }>( + `select (select count(*)::int from auth.users where email like 'service-%') as users, + (select count(*)::int from auth.account where provider_id = 'service') as accounts, + (select count(*)::int from auth.apikey) as keys` + ); + return rows[0]; +} + +const verify = (key: string, permissions: Record = { whatsapp: ['mint'] }) => + service.auth.api.verifyApiKey({ body: { key, permissions } }); + +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +describe('parseArgs', () => { + it('parses each subcommand, the window in seconds stored as milliseconds', () => { + expect(parseArgs(['create-service', 'whatsapp'])).toEqual({ kind: 'create-service', service: 'whatsapp', confirm: null }); + expect(parseArgs(['list', 'ansari-mcp'])).toEqual({ kind: 'list', service: 'ansari-mcp' }); + expect(parseArgs(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa main', '--client-id', 'wa-svc'])).toEqual({ + kind: 'create-key', + service: 'whatsapp', + confirm: null, + limits: INITIAL, + name: 'wa main', + clientId: 'wa-svc', + }); + const id = '3f2a9c1e-0000-4000-8000-00000000000A'; + expect(parseArgs(['revoke', 'whatsapp', '--key', id, '--confirm', 'u'])).toEqual({ + kind: 'revoke', + service: 'whatsapp', + keyId: id.toLowerCase(), + confirm: 'u', + }); + expect(parseArgs(['update-limits', 'whatsapp', '--key', id, ...LIMIT_FLAGS])).toMatchObject({ + kind: 'update-limits', + limits: INITIAL, + }); + }); + + it.each([ + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--expires-in', '86400'], /no expiry/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--expiresIn', '86400'], /no expiry/], + [['rotate', 'whatsapp', ...LIMIT_FLAGS, '--expiration', '1'], /no expiry/], + [['create-key', 'whatsapp', '--rate-max', '60', '--rate-window', '60', '--remaining', '100'], /without a refill/], + [['create-key', 'whatsapp', '--rate-max', '60', '--rate-window', '60'], /no defaults; missing --daily/], + [['create-key', 'whatsapp', '--daily', '10'], /missing --rate-max, --rate-window/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--permissions', 'x'], /not an option/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--daily', '5'], /given twice/], + [['create-key', 'whatsapp', '--rate-max', '1.5', '--rate-window', '60', '--daily', '1'], /whole number/], + [['create-key', 'whatsapp', '--rate-max', '-1', '--rate-window', '60', '--daily', '1'], /needs a value|whole number/], + [['create-key', 'leaderboard', ...LIMIT_FLAGS], /Unknown service/], + [['revoke', 'whatsapp'], /needs --key/], + [['revoke', 'whatsapp', '--key', 'not-an-id'], /key id as list prints it/], + [['list', 'whatsapp', '--confirm', 'x'], /not an option of list/], + [['create-service', 'whatsapp', '--confirm'], /needs a value/], + [['delete', 'whatsapp'], /Usage/], + [[], /Usage/], + ])('refuses %j', (argv, reason) => { + expect(() => parseArgs(argv)).toThrow(RefusedError); + expect(() => parseArgs(argv)).toThrow(reason); + }); +}); + +describe('checkKeyLimits', () => { + const bounds = limits.WHATSAPP_KEY_BOUNDS; + + it('accepts the initial values and both ends of every bound', () => { + expect(checkKeyLimits('whatsapp', INITIAL)).toEqual(INITIAL); + for (const edge of ['min', 'max'] as const) { + const values = { + rateLimitMax: bounds.rateLimitMax[edge], + rateLimitTimeWindow: bounds.rateLimitTimeWindow[edge], + refillAmount: bounds.refillAmount[edge], + refillInterval: limits.KEY_REFILL_INTERVAL, + }; + expect(checkKeyLimits('whatsapp', values)).toEqual(values); + } + }); + + it('refuses a budget without a refill, and a key without a rate limit', () => { + const { refillAmount, refillInterval, ...rest } = INITIAL; + expect(() => checkKeyLimits('whatsapp', { ...rest, refillAmount })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { ...rest, refillInterval })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { ...INITIAL, refillInterval: 3_600_000 })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { refillAmount, refillInterval })).toThrow(/needs a rate limit/); + }); + + it.each([ + ['rateLimitMax', bounds.rateLimitMax.min - 1, /--rate-max/], + ['rateLimitMax', bounds.rateLimitMax.max + 1, /--rate-max/], + ['rateLimitTimeWindow', bounds.rateLimitTimeWindow.min - 1, /--rate-window/], + ['rateLimitTimeWindow', bounds.rateLimitTimeWindow.max + 1, /--rate-window/], + ['refillAmount', bounds.refillAmount.min - 1, /--daily/], + ['refillAmount', bounds.refillAmount.max + 1, /--daily/], + ])('refuses %s = %d', (field, value, reason) => { + expect(() => checkKeyLimits('whatsapp', { ...INITIAL, [field]: value })).toThrow(reason); + }); +}); + +describe('urlHostPort', () => { + it('is hostname and port only, with postgres default port, and null when the URL does not parse', () => { + expect(urlHostPort(DB_URL)).toBe('db.example:6543'); + expect(urlHostPort('postgresql://u:p@DB.Example/auth')).toBe('db.example:5432'); + expect(urlHostPort('not a url with secret')).toBeNull(); + expect(urlHostPort(undefined)).toBeNull(); + }); +}); + +describe('every subcommand', () => { + const KEY_ID = '00000000-0000-4000-8000-000000000001'; + /** Each subcommand as an operator would type it, before --confirm. */ + const SUBCOMMANDS: Record = { + 'create-service': ['create-service', 'whatsapp'], + 'create-key': ['create-key', 'whatsapp', ...LIMIT_FLAGS], + list: ['list', 'whatsapp'], + rotate: ['rotate', 'whatsapp', ...LIMIT_FLAGS], + 'update-limits': ['update-limits', 'whatsapp', '--key', KEY_ID, ...LIMIT_FLAGS], + revoke: ['revoke', 'whatsapp', '--key', KEY_ID], + }; + + it.each(Object.keys(SUBCOMMANDS))('%s refuses to run without a database URL, before connecting', async (name) => { + for (const url of [undefined, '']) { + const { exitCode, text, connect } = await run([...SUBCOMMANDS[name]!, '--confirm', 'x'], { url }); + expect(exitCode).toBe(1); + expect(text).toMatch(/DATABASE_URL is not set/); + expect(connect).not.toHaveBeenCalled(); + } + const unparsable = await run(SUBCOMMANDS[name]!, { url: 'postgres://user:secret@[bad' }); + expect(unparsable.exitCode).toBe(1); + expect(unparsable.text).toMatch(/does not parse/); + expect(unparsable.text).not.toMatch(/secret|\[bad/); + expect(unparsable.connect).not.toHaveBeenCalled(); + }); + + it.each(Object.keys(SUBCOMMANDS))('%s refuses a mismatched --confirm and writes nothing', async (name) => { + let argv = SUBCOMMANDS[name]!; + if (name !== 'create-service') await createService(); + if (name === 'rotate' || name === 'update-limits' || name === 'revoke') { + const { id } = await createKey(); + argv = argv.map((arg) => (arg === KEY_ID ? id : arg)); + } + const before = await rowCounts(); + const { rows: keysBefore } = await service.client.query('select * from auth.apikey order by id'); + const recorder = memoryRecorder(); + const otherId = '00000000-0000-4000-8000-0000000000ff'; + const result = await run([...argv, '--confirm', otherId], { recorder }); + if (name === 'list') { + // list writes nothing and takes no --confirm at all. + expect(result.exitCode).toBe(1); + expect(result.text).toMatch(/not an option of list/); + } else { + expect(result.exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(result.text).toMatch(/Nothing was written/); + } + expect(await rowCounts()).toEqual(before); + expect((await service.client.query('select * from auth.apikey order by id')).rows).toEqual(keysBefore); + expect(recorder.events).toEqual([]); + }); + + it.each(Object.keys(SUBCOMMANDS))( + '%s prints the database as hostname:port only, never the URL, its user, password or query', + async (name) => { + let argv = SUBCOMMANDS[name]!; + if (name !== 'create-service') { + const userId = await createService(); + const { id } = await createKey(); + argv = argv.map((a) => (a === KEY_ID ? id : a)); + // update-limits and revoke confirm with the key's id; the other writes with the user's. + if (name !== 'list') argv = [...argv, '--confirm', name === 'update-limits' || name === 'revoke' ? id : userId]; + } else { + argv = [...argv, '--confirm', 'whatsapp']; + } + for (const url of [DB_URL, DB_URL_DISTINCT]) { + // A write subcommand runs again against the second URL: undo the first write. + if (url === DB_URL_DISTINCT && name === 'create-service') { + await service.client.query(`delete from auth.users where registered_via = 'service'`); + } + const { text, exitCode, print, connect } = await run(argv, { url }); + expect(exitCode, text).toBe(0); + expect(text.split('\n')[0]).toBe('Auth database: db.example:6543'); + // Printed at once, before the script connects (and so before any write), + // and every line printed is the line returned, in order. + expect(print.mock.calls[0]).toEqual(['Auth database: db.example:6543']); + expect(print.mock.invocationCallOrder[0]!).toBeLessThan(connect.mock.invocationCallOrder[0]!); + expect(print.mock.calls.map(([line]) => line)).toEqual(text.split('\n')); + expect(text).toContain('db.example:6543'); + for (const leak of ['user:secret', 'secret', 'sslmode', '/auth?', 'opsname-zq', 'pw-zq', 'authdb-zq', 'verify-full', 'application_name', 'postgres://']) { + expect(text, leak).not.toContain(leak); + } + } + } + ); + + it.each(['create-key', 'rotate', 'update-limits', 'revoke'])('%s logs the key id it acted on', async (name) => { + const userId = await createService(); + const first = await createKey(); + if (name === 'create-key') { + expect(first.text).toContain(`Created key ${first.id}`); + return; + } + const argv = + name === 'rotate' + ? ['rotate', 'whatsapp', ...LIMIT_FLAGS] + : name === 'update-limits' + ? ['update-limits', 'whatsapp', '--key', first.id, ...LIMIT_FLAGS] + : ['revoke', 'whatsapp', '--key', first.id]; + const confirm = name === 'rotate' ? userId : first.id; + const { text, exitCode } = await run([...argv, '--confirm', confirm]); + expect(exitCode).toBe(0); + expect(text).toContain(first.id); + if (name === 'rotate') expect(text).toMatch(/Created key [0-9a-f-]{36}/); + }); +}); + +describe('confirm binding and labels (security PR gate, pass 1)', () => { + it.each(['update-limits', 'revoke'])( + "%s confirms with the KEY's id: the service user's id, or another key's id, writes nothing", + async (name) => { + const userId = await createService(); + const target = await createKey(); + const other = await createKey('rotate'); + const argv = + name === 'update-limits' + ? ['update-limits', 'whatsapp', '--key', target.id, '--rate-max', '1', '--rate-window', '30', '--daily', '500'] + : ['revoke', 'whatsapp', '--key', target.id]; + const shown = await run(argv); + expect(shown.text).toContain(`--confirm ${target.id}`); + const before = await keyRow(target.id); + for (const confirm of [userId, other.id]) { + const result = await run([...argv, '--confirm', confirm]); + expect(result.exitCode, result.text).toBe(EXIT_NOTHING_WRITTEN); + expect(result.text).toMatch(/not this key's id/); + } + expect(await keyRow(target.id)).toEqual(before); + expect((await run([...argv, '--confirm', target.id])).exitCode).toBe(0); + } + ); + + it('rotate prints revoke commands confirmed by each old key id', async () => { + const userId = await createService(); + const old = await createKey(); + const { text } = await run(['rotate', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId]); + expect(text).toContain(`revoke whatsapp --key ${old.id} --confirm ${old.id}`); + }); + + const KEY = `ansari_wa_${'Q'.repeat(64)}`; + it.each([ + ['a full key', KEY], + ['a key without its prefix', 'Q'.repeat(64)], + ['a truncated key', KEY.slice(0, 40)], + ["another service's prefix", 'ansari_mcp_partner'], + ['a newline', 'wa main\ninjected line'], + ['a carriage return', 'wa main\rx'], + ['a tab', 'wa\tmain'], + ['a quote', "wa o'main"], + ['65 characters', 'a'.repeat(65)], + ['a zero-width space', '\u200b'], + ])('--name and --client-id refuse %s, without echoing it', async (_case, value) => { + const userId = await createService(); + for (const flag of ['--name', '--client-id']) { + const before = await rowCounts(); + const recorder = memoryRecorder(); + const result = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, flag, value, '--confirm', userId], { recorder }); + expect(result.exitCode, result.text).toBe(1); + expect(result.text).toContain(flag); + expect(result.text).not.toContain(value); + expect(await rowCounts()).toEqual(before); + expect(recorder.events).toEqual([]); + } + }); + + it.each([['wa main'], ['partner-1.prod_2'], [`${'x'.repeat(31)} ${'y'.repeat(31)}`], ['a'.repeat(31)]])( + '--name and --client-id accept an inert label (%s)', + (value) => { + expect(parseArgs(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', value, '--client-id', value])).toMatchObject({ + name: value, + clientId: value, + }); + } + ); +}); + +describe('create-service', () => { + it('shows what it would create, writes nothing and exits 2', async () => { + const before = await rowCounts(); + const { exitCode, text } = await run(['create-service', 'whatsapp']); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain('service-whatsapp@noreply.askansari.ai'); + expect(text).toContain('--confirm whatsapp'); + expect(await rowCounts()).toEqual(before); + }); + + it('creates the user (no credential, role user) and its service row, and records it', async () => { + const recorder = memoryRecorder(); + const { exitCode, text } = await run(['create-service', 'whatsapp', '--confirm', 'whatsapp'], { recorder }); + expect(exitCode).toBe(0); + const id = await serviceUserId(); + expect(text).toContain(id); + const { rows } = await service.client.query>( + `select u.email, u.role, u.registered_via, a.provider_id, a.account_id, a.password + from auth.users u join auth.account a on a.user_id = u.id where u.id = $1`, + [id] + ); + expect(rows).toEqual([ + { + email: 'service-whatsapp@noreply.askansari.ai', + role: 'user', + registered_via: 'service', + provider_id: 'service', + account_id: 'whatsapp', + password: null, + }, + ]); + expect(recorder.events).toEqual([{ action: 'create-service', service: 'whatsapp', userId: id, keyId: null, name: null }]); + }); + + it('refuses an existing service name', async () => { + await createService(); + for (const argv of [['create-service', 'whatsapp'], ['create-service', 'whatsapp', '--confirm', 'whatsapp']]) { + const { exitCode, text } = await run(argv); + expect(exitCode).toBe(1); + expect(text).toMatch(/already has a service user/); + } + expect((await rowCounts())?.users).toBe(1); + }); + + it('writes the user and the service row in one transaction: a failed row leaves neither', async () => { + // Another user already holds the (service, whatsapp) row, so the second insert fails. + const { rows } = await service.client.query<{ id: string }>( + `insert into auth.users (name, email) values ('squatter', 'squatter-${Date.now()}@example.com') returning id` + ); + await service.client.query( + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ('whatsapp', 'service', $1, now())`, + [rows[0]!.id] + ); + await expect(serviceUsers.createServiceUser(service.db, 'whatsapp')).rejects.toThrow(); + const { rows: users } = await service.client.query(`select id from auth.users where email = 'service-whatsapp@noreply.askansari.ai'`); + expect(users).toEqual([]); + await service.client.query('delete from auth.users where id = $1', [rows[0]!.id]); + }); + + it('findServiceUser reports none, one and many', async () => { + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toEqual({ kind: 'none' }); + const id = await createService(); + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toMatchObject({ kind: 'one', user: { id } }); + // Only possible without unique(provider_id, account_id): drop it for this check. + await service.client.query('alter table auth.account drop constraint account_provider_id_account_id_unique'); + try { + const { rows } = await service.client.query<{ id: string }>( + `insert into auth.users (name, email, registered_via) values ('x', 'second-service@noreply.askansari.ai', 'service') returning id` + ); + await service.client.query( + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ('whatsapp', 'service', $1, now())`, + [rows[0]!.id] + ); + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toMatchObject({ kind: 'many' }); + const { exitCode, text } = await run(['list', 'whatsapp']); + expect(exitCode).toBe(1); + expect(text).toMatch(/2 users claim service "whatsapp"; refusing|2 users claim service "whatsapp" \(/); + } finally { + await service.client.query(`delete from auth.users where registered_via = 'service'`); + await service.client.query( + 'alter table auth.account add constraint account_provider_id_account_id_unique unique (provider_id, account_id)' + ); + } + }); + + it('key commands refuse a service with no user', async () => { + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS]); + expect(exitCode).toBe(1); + expect(text).toMatch(/No service user for "whatsapp"/); + }); +}); + +describe('create-key', () => { + it('shows the service user and the limits, writes nothing and exits 2', async () => { + const userId = await createService(); + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa main']); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain(`id ${userId}`); + expect(text).toContain('60 per 60 s; budget 3000 per 24 h'); + expect(text).toContain(`--name 'wa main' --confirm ${userId}`); + expect((await rowCounts())?.keys).toBe(0); + }); + + it('prints the raw key exactly once, and the row holds only its hash', async () => { + await createService(); + const { key, id, text, userId } = await createKey('create-key', [...LIMIT_FLAGS, '--client-id', 'wa-partner']); + expect(occurrences(text, key)).toBe(1); + expect(text.match(WA_KEY)).toHaveLength(1); + expect(text).toMatch(/SHOWN ONCE/); + + const row = await keyRow(id); + for (const [column, value] of Object.entries(row!)) { + if (typeof value === 'string') expect(value, column).not.toContain(key.slice(3)); + } + // The plugin's hash: SHA-256, base64url (packages/auth's probe 1 pins it to defaultKeyHasher). + expect(row!.key).toBe(createHash('sha256').update(key).digest('base64url')); + expect(text).not.toContain(row!.key as string); + expect(row).toMatchObject({ + reference_id: userId, + prefix: 'ansari_wa_', + start: null, + enabled: true, + rate_limit_enabled: true, + rate_limit_max: INITIAL.rateLimitMax, + rate_limit_time_window: INITIAL.rateLimitTimeWindow, + remaining: INITIAL.refillAmount, + refill_amount: INITIAL.refillAmount, + refill_interval: INITIAL.refillInterval, + expires_at: null, + }); + expect(JSON.parse(row!.permissions as string)).toEqual({ whatsapp: ['mint'] }); + expect(JSON.parse(row!.metadata as string)).toEqual({ clientId: 'wa-partner' }); + expect((await verify(key)).valid).toBe(true); + // A whatsapp key is refused at another service's permission. + expect((await verify(key, { mcp: ['complete'] })).valid).toBe(false); + }); + + it('refuses out-of-bounds limits before writing', async () => { + const userId = await createService(); + const { exitCode, text } = await run([ + 'create-key', 'whatsapp', '--rate-max', '100001', '--rate-window', '60', '--daily', '3000', '--confirm', userId, + ]); + expect(exitCode).toBe(1); + expect(text).toMatch(/Out of bounds.*--rate-max/); + expect((await rowCounts())?.keys).toBe(0); + }); + + it('refuses an expiry option and a bare budget at the command line', async () => { + const userId = await createService(); + for (const extra of [['--expires-in', '86400'], ['--remaining', '10']]) { + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, ...extra, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/no expiry|without a refill/); + } + expect((await rowCounts())?.keys).toBe(0); + }); +}); + +describe('list', () => { + it('prints id, name, prefix, permissions, enabled, limits and dates per key; never a key or a hash', async () => { + await createService(); + const a = await createKey('create-key', [...LIMIT_FLAGS, '--name', 'wa-a']); + const b = await createKey('rotate', [...LIMIT_FLAGS, '--name', 'wa-b']); + await verify(a.key); + const { exitCode, text } = await run(['list', 'whatsapp']); + expect(exitCode).toBe(0); + expect(text).toContain('2 key(s):'); + for (const { id, key } of [a, b]) { + expect(text).toContain(`key ${id}`); + expect(text).not.toContain(key); + expect(text).not.toContain(key.slice(3, 20)); + expect(text).not.toContain((await keyRow(id))!.key as string); + } + for (const field of ['name wa-a', 'name wa-b', 'prefix ansari_wa_', 'permissions {"whatsapp":["mint"]}', 'enabled true', 'limits 60 per 60 s; budget 3000 per 24 h', 'created 20', 'last request 20', 'last request never']) { + expect(text).toContain(field); + } + expect(text.match(WA_KEY)).toBeNull(); + }); +}); + +describe('rotate', () => { + it('creates a second live key, both verify; prints the new key once and the old key id', async () => { + await createService(); + const old = await createKey(); + const fresh = await createKey('rotate'); + expect(fresh.id).not.toBe(old.id); + expect(occurrences(fresh.text, fresh.key)).toBe(1); + expect(fresh.text.match(WA_KEY)).toHaveLength(1); + expect(fresh.text).toContain(`Old live key(s): ${old.id}`); + expect(fresh.text).toContain(`revoke whatsapp --key ${old.id}`); + expect((await verify(old.key)).valid).toBe(true); + expect((await verify(fresh.key)).valid).toBe(true); + }); + + it('refuses when there is no live key to rotate', async () => { + const userId = await createService(); + const { exitCode, text } = await run(['rotate', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/no live key to rotate/); + }); +}); + +describe('revoke', () => { + it('shows the key and writes nothing without --confirm', async () => { + await createService(); + const { id } = await createKey(); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', id]); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain(`key ${id}`); + expect((await keyRow(id))!.enabled).toBe(true); + }); + + it('sets enabled = false and keeps the row; the key no longer verifies; prints the id, no key', async () => { + await createService(); + const { id, key } = await createKey(); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', id, '--confirm', id]); + expect(exitCode).toBe(0); + expect(text).toContain(`Revoked key ${id}`); + expect(text).toContain('enabled = false'); + expect(text).not.toContain(key); + const row = await keyRow(id); + expect(row).toBeDefined(); + expect(row!.enabled).toBe(false); + const result = await verify(key); + expect(result.valid).toBe(false); + expect(await keyRow(id)).toBeDefined(); + }); + + it("refuses a key that is not the service's", async () => { + await createService(); + await createService('ansari-mcp'); + const mcp = await createKey('create-key', LIMIT_FLAGS, 'ansari-mcp'); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', mcp.id, '--confirm', mcp.id]); + expect(exitCode).toBe(1); + expect(text).toMatch(/not a key of this service/); + expect((await keyRow(mcp.id))!.enabled).toBe(true); + }); +}); + +describe('update-limits', () => { + it('writes the new values, enforced at the next verify', async () => { + await createService(); + const { id, key } = await createKey(); + const { exitCode, text } = await run([ + 'update-limits', 'whatsapp', '--key', id, '--rate-max', '1', '--rate-window', '30', '--daily', '500', '--confirm', id, + ]); + expect(exitCode, text).toBe(0); + expect(text).toContain(`Updated key ${id}`); + expect(await keyRow(id)).toMatchObject({ + rate_limit_max: 1, + rate_limit_time_window: 30_000, + refill_amount: 500, + remaining: 500, + refill_interval: limits.KEY_REFILL_INTERVAL, + enabled: true, + }); + expect((await verify(key)).valid).toBe(true); + const second = await verify(key); + expect(second.valid).toBe(false); + expect(second.error?.code).toBe('RATE_LIMITED'); + }); + + it('refuses out-of-bounds values and writes nothing', async () => { + await createService(); + const { id } = await createKey(); + const before = await keyRow(id); + for (const flags of [ + ['--rate-max', '0', '--rate-window', '60', '--daily', '10'], + ['--rate-max', '10', '--rate-window', '86401', '--daily', '10'], + ['--rate-max', '10', '--rate-window', '60', '--daily', '10000001'], + ]) { + const { exitCode, text } = await run(['update-limits', 'whatsapp', '--key', id, ...flags, '--confirm', id]); + expect(exitCode).toBe(1); + expect(text).toMatch(/Out of bounds/); + } + expect(await keyRow(id)).toEqual(before); + }); +}); + +describe('SECRET_SCANNING_PATTERNS', () => { + it('every service prefix is ansari__, unique, and not the start of another', () => { + const prefixes = Object.values(serviceUsers.SERVICES).map((s) => s.prefix as string); + for (const prefix of prefixes) expect(prefix).toMatch(/^ansari_[a-z]+_$/); + expect(new Set(prefixes).size).toBe(prefixes.length); + for (const a of prefixes) for (const b of prefixes) if (a !== b) expect(b.startsWith(a), `${a} starts ${b}`).toBe(false); + }); + + it('has one pattern per service, on its prefix', () => { + expect(Object.keys(serviceUsers.SECRET_SCANNING_PATTERNS).sort()).toEqual(Object.keys(serviceUsers.SERVICES).sort()); + for (const [name, pattern] of Object.entries(serviceUsers.SECRET_SCANNING_PATTERNS)) { + expect(pattern.source.startsWith(serviceUsers.SERVICES[name as serviceUsers.ServiceName].prefix)).toBe(true); + } + }); + + it("matches a key freshly created for its service, and not a 63-letter tail, the other service's key or a session token", async () => { + await createService('whatsapp'); + await createService('ansari-mcp'); + const wa = (await createKey('create-key', LIMIT_FLAGS, 'whatsapp')).key; + const mcp = (await createKey('create-key', LIMIT_FLAGS, 'ansari-mcp')).key; + const { whatsapp, 'ansari-mcp': amcp } = serviceUsers.SECRET_SCANNING_PATTERNS; + const { rows } = await service.client.query<{ token: string }>('select token from auth.session limit 1'); + const sessionToken = rows[0]?.token ?? (await import('better-auth/crypto')).generateRandomString(32); + expect(sessionToken).toHaveLength(32); + + const cases: [RegExp, string, boolean][] = [ + [whatsapp, wa, true], + [amcp, mcp, true], + [whatsapp, wa.slice(0, -1), false], + [amcp, mcp.slice(0, -1), false], + [whatsapp, mcp, false], + [amcp, wa, false], + [whatsapp, sessionToken, false], + [amcp, sessionToken, false], + [whatsapp, `ansari_wa_${sessionToken}`, false], + ]; + const hits = cases.map(([pattern, candidate, expected]) => { + expect(pattern.test(candidate), `${pattern.source} vs ${candidate.slice(0, 8)}…`).toBe(expected); + return pattern.test(candidate); + }); + expect(hits.filter(Boolean)).toHaveLength(2); + }); +}); + +describe('the durable record', () => { + const envelopes: unknown[] = []; + let transportFlushes: boolean; + + beforeAll(() => { + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + defaultIntegrations: false, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => transportFlushes, + }), + }); + }); + afterAll(async () => { + await Sentry.close(); + }); + beforeEach(() => { + envelopes.length = 0; + transportFlushes = true; + }); + + it('create, rotate, update-limits and revoke each send one Sentry event with no key material', async () => { + const userId = await createService(); + envelopes.length = 0; + const first = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa-1', '--confirm', userId], { + recorder: sentryRecorder, + }); + const key1 = first.lines.find((l) => l.startsWith('ansari_wa_'))!; + const id1 = /Created key ([0-9a-f-]{36})/.exec(first.text)![1]!; + const rotated = await run(['rotate', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa-2', '--confirm', userId], { + recorder: sentryRecorder, + }); + const key2 = rotated.lines.find((l) => l.startsWith('ansari_wa_'))!; + const id2 = /Created key ([0-9a-f-]{36})/.exec(rotated.text)![1]!; + await run(['update-limits', 'whatsapp', '--key', id2, ...LIMIT_FLAGS, '--confirm', id2], { recorder: sentryRecorder }); + await run(['revoke', 'whatsapp', '--key', id1, '--confirm', id1], { recorder: sentryRecorder }); + await run(['list', 'whatsapp'], { recorder: sentryRecorder }); + + expect(envelopes).toHaveLength(4); + const serialized = JSON.stringify(envelopes); + expect(occurrences(serialized, KEY_EVENT_MESSAGE)).toBe(4); + for (const [action, id, name] of [ + ['create-key', id1, 'wa-1'], + ['rotate', id2, 'wa-2'], + ['update-limits', id2, 'wa-2'], + ['revoke', id1, 'wa-1'], + ] as const) { + const event = envelopes.map((e) => JSON.stringify(e)).find((e) => e.includes(`"action":"${action}"`)); + expect(event, action).toBeDefined(); + expect(event).toContain(id); + expect(event).toContain(`"name":"${name}"`); + expect(event).toContain('"service":"whatsapp"'); + } + for (const key of [key1, key2]) { + expect(serialized).not.toContain(key); + expect(serialized).not.toContain(key.slice(3, 20)); + } + for (const id of [id1, id2]) expect(serialized).not.toContain((await keyRow(id))!.key as string); + }); + + it('awaits the flush before it returns', async () => { + await createService(); + let release!: (ok: boolean) => void; + const recorder: Recorder = { + capture: () => {}, + flush: () => new Promise((resolve) => (release = resolve)), + }; + let settled = false; + const { id } = await createKey(); + const pending = run(['revoke', 'whatsapp', '--key', id, '--confirm', id], { recorder }).then((r) => { + settled = true; + return r; + }); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await new Promise((r) => setTimeout(r, 50)); + expect(settled).toBe(false); + release(true); + expect((await pending).exitCode).toBe(0); + }); + + it('a failed flush keeps the write, prints the key-log banner and exits non-zero', async () => { + const userId = await createService(); + transportFlushes = false; + const { exitCode, text, lines } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId], { + recorder: sentryRecorder, + flushTimeoutMs: 200, + }); + expect(exitCode).toBe(EXIT_UNRECORDED); + expect(exitCode).not.toBe(0); + expect(text).toMatch(/SENTRY RECORD OF THIS ACTION WAS NOT SENT/); + expect(text).toMatch(/runbook's key log/); + const id = /Created key ([0-9a-f-]{36})/.exec(text)![1]!; + expect(text).toMatch(new RegExp(`action create-key, service whatsapp, user ${userId}, key ${id}`)); + // The write stands, and the key was still shown once (it is not stored anywhere else). + expect(await keyRow(id)).toMatchObject({ enabled: true }); + const key = lines.find((l) => l.startsWith('ansari_wa_'))!; + expect(occurrences(text, key)).toBe(1); + expect((await verify(key)).valid).toBe(true); + }); + + it('a recorder that reports an unsent flush is a failure too', async () => { + await createService(); + const recorder = memoryRecorder(false); + const { id } = await createKey(); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', id, '--confirm', id], { recorder }); + expect(recorder.flushes).toBe(1); + expect(exitCode).toBe(EXIT_UNRECORDED); + expect(text).toMatch(/NOT SENT/); + }); +}); diff --git a/apps/auth/tests/whatsapp-session-sentry.test.ts b/apps/auth/tests/whatsapp-session-sentry.test.ts new file mode 100644 index 00000000..4cfb0634 --- /dev/null +++ b/apps/auth/tests/whatsapp-session-sentry.test.ts @@ -0,0 +1,99 @@ +// The exchange under the service's own Sentry options (spec 329: Sentry events +// from the exchange carry no request body, no Authorization header and no +// phone number). Default integrations stay on, as in production; only the +// transport is a spy. Its own file: the Console integration patches `console` +// for the whole process. +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { serviceUsers } from '@ansari/auth'; + +import { EXCHANGE_ALERT_MESSAGE, SERVICE_SENTRY_OPTIONS } from '../src/alerts'; +import { WHATSAPP_SESSION_PATH } from '../src/routes/whatsapp'; +import { startService, type Service } from './helpers/service'; + +const envelopes: unknown[] = []; +let service: Service; + +beforeAll(async () => { + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + environment: 'test', + ...SERVICE_SENTRY_OPTIONS, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => true, + }), + }); + service = await startService(); +}); + +afterAll(async () => { + await Sentry.close(); + await service?.close(); +}); + +const post = (key: string, body: unknown) => + fetch(`${service.publicUrl}${WHATSAPP_SESSION_PATH}`, { + method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +describe('Sentry events from the exchange', () => { + // Runs first: the next case removes the whatsapp service user's `service` row. + it("carry no ineligible user's id (the log line names it; no console breadcrumb rides on the alert)", async () => { + const owner = await serviceUsers.createServiceUser(service.db, 'whatsapp'); + const { key } = await service.auth.api.createApiKey({ + body: { userId: owner.id, prefix: 'ansari_wa_', permissions: { whatsapp: ['mint'] }, rateLimitEnabled: false }, + }); + const bsuid = 'US.27182818284590452353'; + const first = await post(key, { bsuid }); + expect(first.status).toBe(200); + const { token } = (await first.json()) as { token: string }; + const sub = (JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) as { sub: string }).sub; + // Promoted to admin: the next exchange is ineligible (500), logs the id and raises the alert. + await service.client.query(`update auth.users set role = 'admin' where id = $1`, [sub]); + envelopes.length = 0; + expect((await post(key, { bsuid })).status).toBe(500); + await Sentry.flush(2000); + + const serialized = JSON.stringify(envelopes); + expect(serialized).toContain('ineligible-user'); + for (const needle of [sub, bsuid, key, key.slice(3, 20), token]) { + expect(serialized).not.toContain(needle); + } + // Leave the service user as the next case expects to create it: gone. + await service.client.query('delete from auth.users where id = $1 or id = $2', [owner.id, sub]); + }); + + it('carry no body, Authorization header, BSUID, phone number or key', async () => { + // A key whose owner has lost its `service` row: the route raises the + // service-user alert while the request (with its body) is in flight. + const owner = await serviceUsers.createServiceUser(service.db, 'whatsapp'); + const { key } = await service.auth.api.createApiKey({ + body: { userId: owner.id, prefix: 'ansari_wa_', permissions: { whatsapp: ['mint'] }, rateLimitEnabled: false }, + }); + await service.client.query('delete from auth.account where user_id = $1', [owner.id]); + + const bsuid = 'US.48151623420815162342'; + const phone = '15550001111'; + const res = await fetch(`${service.publicUrl}${WHATSAPP_SESSION_PATH}`, { + method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ bsuid, phone_number: phone }), + }); + expect(res.status).toBe(500); + await Sentry.flush(2000); + + const serialized = JSON.stringify(envelopes); + expect(serialized).toContain(EXCHANGE_ALERT_MESSAGE); + for (const needle of [bsuid, phone, key, key.slice(3, 20), 'phone_number', 'bsuid']) { + expect(serialized).not.toContain(needle); + } + expect(serialized.toLowerCase()).not.toContain('authorization'); + }); +}); diff --git a/apps/auth/tests/whatsapp-session.test.ts b/apps/auth/tests/whatsapp-session.test.ts new file mode 100644 index 00000000..3c085c90 --- /dev/null +++ b/apps/auth/tests/whatsapp-session.test.ts @@ -0,0 +1,818 @@ +// POST /api/whatsapp/session (spec 329, plan phase 6), over HTTP through +// startService on pglite built from the auth journal. The decision under the +// lock is pinned in packages/auth `tests/whatsapp.test.ts`; this file owns the +// route: the key refusals, the body, the statuses, transport (CORS, method, +// path), the outage mapping, the alerts, and what reaches responses and logs. +// A lock held from another connection and a stopped database need real +// Postgres: scripts/probes/exchange-outage-probe.ts, output in the review. +import { createHash, randomInt } from 'node:crypto'; +import { inspect } from 'node:util'; + +import { createLocalJWKSet, jwtVerify } from 'jose'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { limits, serviceUsers } from '@ansari/auth'; + +import { TEST_JWT_AUDIENCE, TEST_JWT_ISSUER } from '../../../packages/auth/tests/helpers/auth-db'; +import { + createExchangeAlerts, + INELIGIBLE_USER_MESSAGE, + scrubRequestEvent, + type ExchangeAlert, +} from '../src/alerts'; +import { createApp } from '../src/app'; +import { exchangeLimitKey, WHATSAPP_SESSION_PATH } from '../src/routes/whatsapp'; +import { BASE, rateLimitRow, send, signUpTokens, startService, type Service } from './helpers/service'; + +const WHATSAPP = { whatsapp: ['mint'] }; + +/** The plugin's key hash: SHA-256, base64url (packages/auth's probe 1 pins it to defaultKeyHasher). */ +const keyHash = (raw: string) => createHash('sha256').update(raw).digest('base64url'); + +/** Every console line written while this file runs, for the log-capture assertions at the end. */ +const logged: unknown[][] = []; +const consoleSpies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((level) => + vi.spyOn(console, level).mockImplementation((...args: unknown[]) => { + logged.push(args); + }) +); + +/** Every value that must never reach a response or a log: raw keys, BSUIDs, phone numbers, bodies. */ +const secrets = new Set(); +const remember = (value: T): T => { + secrets.add(value); + return value; +}; + +const digits = (n: number) => Array.from({ length: n }, () => randomInt(10)).join(''); +const newBsuid = () => remember(`US.${digits(20)}`); +const newPhone = () => remember(`1${digits(11)}`); + +class Clock { + at = Date.now(); + now = () => this.at; + advance(ms: number) { + this.at += ms; + } +} + +const clock = new Clock(); +const alerts: ExchangeAlert[] = []; +const alertsOf = (kind: ExchangeAlert['kind']) => alerts.filter((a) => a.kind === kind); + +let service: Service; +let serviceUserId: string; +let key: string; + +/** A fresh exchange-alert window, so one test's counts never reach the next. */ +const nextAlertWindow = () => clock.advance(limits.EXCHANGE_ALERT_WINDOW * 1000); + +function startExchangeService(): Promise { + return startService({ + exchange: { + alerts: createExchangeAlerts({ send: (alert) => alerts.push(alert), now: clock.now }), + now: clock.now, + }, + }); +} + +beforeAll(async () => { + service = await startExchangeService(); + serviceUserId = (await serviceUsers.createServiceUser(service.db, 'whatsapp')).id; + key = await createKey(); +}); + +afterAll(async () => { + await service?.close(); + for (const spy of consoleSpies) spy.mockRestore(); +}); + +type KeyBody = Record; + +/** A key created in process, as the service-keys script does, owned by `userId`. */ +async function createKey(body: KeyBody = {}, on: Service = service, userId = serviceUserId) { + const created = await on.auth.api.createApiKey({ + body: { userId, prefix: 'ansari_wa_', name: 'test', permissions: WHATSAPP, rateLimitEnabled: false, ...body }, + }); + remember(keyHash(created.key)); + return remember(created.key); +} + +type PostOptions = { + bearer?: string | null; + authorization?: string; + contentType?: string | null; + headers?: Record; + method?: string; + path?: string; + on?: Service; +}; + +type Exchanged = { status: number; text: string; headers: Headers }; + +/** + * A raw request to the exchange. Every response is checked for the request's + * sensitive values (the bearer, the raw body and each value in it). + */ +async function post(body: unknown, opts: PostOptions = {}): Promise { + const raw = typeof body === 'string' ? body : JSON.stringify(body); + const headers: Record = { ...opts.headers }; + const bearer = opts.bearer === undefined ? key : opts.bearer; + if (opts.authorization !== undefined) headers.authorization = opts.authorization; + else if (bearer !== null) headers.authorization = `Bearer ${bearer}`; + if (opts.contentType !== null) headers['content-type'] = opts.contentType ?? 'application/json'; + const res = await fetch(`${(opts.on ?? service).publicUrl}${opts.path ?? WHATSAPP_SESSION_PATH}`, { + method: opts.method ?? 'POST', + headers, + body: raw, + }); + const text = await res.text(); + const sensitive = [raw, ...(bearer ? [bearer] : [])]; + if (body && typeof body === 'object') { + sensitive.push(...Object.values(body).filter((v): v is string => typeof v === 'string' && v.length > 2)); + } + for (const value of sensitive) if (value.length > 2) expect(text).not.toContain(value); + return { status: res.status, text, headers: res.headers }; +} + +async function exchange(bsuid = newBsuid(), phone?: string, opts: PostOptions = {}) { + return post(phone === undefined ? { bsuid } : { bsuid, phone_number: phone }, opts); +} + +async function tokenOf(res: Exchanged): Promise { + expect(res.status, res.text).toBe(200); + return (JSON.parse(res.text) as { token: string }).token; +} + +async function claims(token: string, on: Service = service) { + const { payload } = await jwtVerify(token, createLocalJWKSet(await on.auth.api.getJwks()), { + issuer: TEST_JWT_ISSUER, + audience: TEST_JWT_AUDIENCE, + }); + return payload as { sub: string; sid: string }; +} + +async function scalar(sql: string, params: unknown[] = [], on: Service = service): Promise { + const { rows } = await on.client.query<{ n: number }>(sql, params); + return Number(rows[0]?.n ?? 0); +} + +async function rowCounts(on: Service = service) { + return { + users: await scalar('select count(*)::int as n from auth.users', [], on), + accounts: await scalar('select count(*)::int as n from auth.account', [], on), + sessions: await scalar('select count(*)::int as n from auth.session', [], on), + }; +} + +const sessionCount = (userId: string) => + scalar('select count(*)::int as n from auth.session where user_id = $1', [userId]); + +const whatsappUserOf = async (bsuid: string) => { + const { rows } = await service.client.query<{ user_id: string }>( + `select user_id from auth.account where provider_id = 'whatsapp' and account_id = $1`, + [bsuid] + ); + return rows[0]?.user_id; +}; + +const UNAUTHORIZED = '{"message":"Unauthorized"}'; +const INVALID = '{"message":"Invalid request"}'; +const INTERNAL = '{"message":"Internal error"}'; + +describe('key refusals: one byte-identical 401, no rows', () => { + async function seededKey(sql: string, body: KeyBody = {}) { + const raw = await createKey(body); + const hash = keyHash(raw); + await service.client.query(sql, [hash]); + return raw; + } + + const cases: [string, () => Promise][] = [ + ['no Authorization header', async () => ({ bearer: null })], + ['a non-Bearer scheme', async () => ({ authorization: `Basic ${key}` })], + ['an empty bearer', async () => ({ authorization: 'Bearer ' })], + ['a session token', async () => ({ bearer: remember((await signUpTokens(service, 'wa-session@example.com')).sessionToken) })], + ['a JWT', async () => ({ bearer: (await signUpTokens(service, 'wa-jwt@example.com')).jwt })], + ['an unknown key', async () => ({ bearer: remember(`ansari_wa_${'A'.repeat(64)}`) })], + ['a disabled key', async () => ({ bearer: await seededKey('update auth.apikey set enabled = false where key = $1') })], + ['an expired key', async () => ({ bearer: await seededKey(`update auth.apikey set expires_at = now() - interval '1 minute' where key = $1`) })], + [ + 'an exhausted key (refilled daily)', + async () => ({ + bearer: await seededKey('update auth.apikey set remaining = 0, last_refill_at = now() where key = $1', { + remaining: 5, + refillAmount: 5, + refillInterval: limits.KEY_REFILL_INTERVAL, + }), + }), + ], + [ + 'a rate-limited key', + async () => ({ + bearer: await seededKey('update auth.apikey set request_count = 1, last_request = now() where key = $1', { + rateLimitEnabled: true, + rateLimitMax: 1, + rateLimitTimeWindow: 60_000, + }), + }), + ], + ['a key with mcp: complete only', async () => ({ bearer: await createKey({ permissions: { mcp: ['complete'] } }) })], + ['a key with no permissions', async () => ({ bearer: await seededKey('update auth.apikey set permissions = null where key = $1') })], + [ + "another service's whatsapp: mint key", + async () => { + const other = await serviceUsers.createServiceUser(service.db, 'ansari-mcp'); + return { bearer: await createKey({}, service, other.id) }; + }, + ], + ]; + + it.each(cases)('%s → 401, and no user, account or session is created', async (_name, options) => { + const opts = await options(); + const before = await rowCounts(); + const res = await exchange(newBsuid(), newPhone(), opts); + expect(res.status).toBe(401); + expect(res.text).toBe(UNAUTHORIZED); + expect(res.headers.get('content-type')).toBe('application/json; charset=utf-8'); + expect(res.headers.get('content-length')).toBe(String(UNAUTHORIZED.length)); + expect(await rowCounts()).toEqual(before); + }); + + it('USAGE_EXCEEDED and RATE_LIMITED each raised one alert in the window, however many refusals', () => { + const refused = alertsOf('key-refused'); + expect(refused.map((a) => a.kind === 'key-refused' && a.code).sort()).toEqual(['RATE_LIMITED', 'USAGE_EXCEEDED']); + }); + + it('a missing or malformed bearer is answered without touching the database', async () => { + // Past the relation-check window, so a check run here would be a query. + clock.advance(limits.APIKEY_RELATION_CHECK_TTL); + const methods = ['query', 'exec', 'transaction', 'sql'] as const; + const spies = methods.map((m) => vi.spyOn(service.client, m)); + try { + for (const authorization of [undefined, 'Basic abc', 'Bearer ', 'Bearer a b', 'bearer']) { + const res = await post({ bsuid: newBsuid() }, authorization === undefined ? { bearer: null } : { authorization }); + expect(res.status).toBe(401); + } + expect(spies.map((s) => s.mock.calls.length)).toEqual([0, 0, 0, 0]); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + it('a body sent with no key is never parsed', async () => { + const marker = remember(`{"bsuid":"never-parsed-${digits(8)}"`); + const parse = vi.spyOn(JSON, 'parse'); + try { + const res = await post(`${marker}}`, { bearer: null }); + expect(res.status).toBe(401); + expect(parse.mock.calls.some(([text]) => typeof text === 'string' && text.includes(marker))).toBe(false); + } finally { + parse.mockRestore(); + } + }); +}); + +describe('the exchange', () => { + it('a new BSUID → 200 with a JWT for a new WhatsApp user and its one session', async () => { + const bsuid = newBsuid(); + const before = await rowCounts(); + const { sub, sid } = await claims(await tokenOf(await exchange(bsuid, newPhone()))); + expect(await whatsappUserOf(bsuid)).toBe(sub); + expect(await rowCounts()).toEqual({ + users: before.users + 1, + accounts: before.accounts + 1, + sessions: before.sessions + 1, + }); + expect(await scalar('select count(*)::int as n from auth.session where id = $1 and user_id = $2', [sid, sub])).toBe(1); + }); + + it('the same BSUID twice → the same sub and sid, one session', async () => { + const bsuid = newBsuid(); + const first = await claims(await tokenOf(await exchange(bsuid))); + const second = await claims(await tokenOf(await exchange(bsuid))); + expect(second).toMatchObject({ sub: first.sub, sid: first.sid }); + expect(await sessionCount(first.sub)).toBe(1); + }); + + it('the token passes GET /api/account/me, which never returns the phone number', async () => { + const phone = newPhone(); + const token = await tokenOf(await exchange(newBsuid(), phone)); + const me = await send(service.publicUrl, '/api/account/me', { bearer: token }); + expect(me.status).toBe(200); + const text = await me.text(); + expect(JSON.parse(text)).toMatchObject({ registered_via: 'whatsapp', role: 'user', banned: false }); + expect(text).not.toContain(phone); + }); + + it.each([ + ['lower case', 'us.13491208655302741918'], + ["Meta's parent form", 'US.ENT.13491208655302741918'], + ['punctuation', 'x_y-z.1:2/3+4=5'], + ['256 characters', 'A'.repeat(256)], + ])('a sane but unusual BSUID (%s) is accepted', async (_name, bsuid) => { + remember(bsuid); + expect((await exchange(bsuid)).status).toBe(200); + }); + + it.each(['bearer', 'BEARER', 'BeArEr'])('the %s scheme is the Bearer scheme (RFC 7235: case-insensitive)', async (scheme) => { + const res = await post({ bsuid: newBsuid() }, { authorization: `${scheme} ${key}` }); + expect(res.status, res.text).toBe(200); + }); + + /** Every row of every table in the auth schema, as JSON text, for a needle scan. */ + async function authRowsText(): Promise { + const { rows: tables } = await service.client.query<{ table_name: string }>( + `SELECT table_name FROM information_schema.tables WHERE table_schema = 'auth' ORDER BY table_name` + ); + expect(tables.length).toBeGreaterThan(5); + const parts: string[] = []; + for (const { table_name } of tables) { + const { rows } = await service.client.query<{ row: unknown }>(`SELECT row_to_json(t) AS row FROM auth."${table_name}" t`); + parts.push(JSON.stringify(rows)); + } + return parts.join('\n'); + } + + // Spec 329 §5, owner amendment 2: phone_number is accepted in any shape and ignored. + // Each shape returns the value sent and the needle that must appear in no row. + it.each([ + ['digits', () => { const v = remember(`1${digits(11)}`); return [v, v] as const; }], + ['a leading +', () => { const v = remember(`+${digits(12)}`); return [v, v] as const; }], + ['a JSON number', () => { const n = remember(`7${digits(10)}`); return [Number(n), n] as const; }], + ['a nested object', () => { const n = remember(`5${digits(11)}`); return [{ e164: n, country: 'US' }, n] as const; }], + ['null', () => [null, null] as const], + ['an empty string', () => ['', null] as const], + ['33 characters', () => { const v = remember(`9${digits(32)}`); return [v, v] as const; }], + ['letters and spaces', () => { const v = remember(`abc ${digits(8)} xyz`); return [v, v] as const; }], + ] as const)('phone_number as %s → 200, and the number is in no auth row', async (_name, make) => { + const [value, needle] = make(); + const bsuid = newBsuid(); + const res = await post({ bsuid, phone_number: value }); + expect(res.status, res.text).toBe(200); + const rows = await authRowsText(); + expect(rows).toContain(bsuid); // the scan sees this exchange's own writes + if (needle !== null) expect(rows).not.toContain(needle); + expect(res.text).not.toContain('phone'); + }); + + it('a maximal valid body (a 256-character BSUID and a long phone_number) → 200, the number in no row', async () => { + const bsuid = remember(`M.${'7'.repeat(254)}`); + const number = remember(`3${digits(500)}`); + const body = JSON.stringify({ bsuid, phone_number: number }); + expect(Buffer.byteLength(body)).toBeLessThan(limits.EXCHANGE_BODY_LIMIT); + const res = await post(body); + expect(res.status, res.text).toBe(200); + const rows = await authRowsText(); + expect(rows).toContain(bsuid); + expect(rows).not.toContain(number); + }); +}); + +describe('delegation inputs are just unknown BSUIDs', () => { + it("an admin's and an email user's id and email, and a google user's account_id, each create a new WhatsApp user", async () => { + const admin = await signUpTokens(service, 'wa-admin@example.com'); + await service.client.query(`update auth.users set role = 'admin' where id = $1`, [admin.id]); + const member = await signUpTokens(service, 'wa-member@example.com'); + const googleId = remember(`g-${digits(21)}`); + await service.client.query( + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ($1, 'google', $2, now())`, + [googleId, member.id] + ); + const seeded = [admin.id, member.id]; + for (const input of [admin.id, 'wa-admin@example.com', member.id, 'wa-member@example.com', googleId]) { + const { sub } = await claims(await tokenOf(await exchange(input))); + expect(seeded).not.toContain(sub); + expect(await whatsappUserOf(input)).toBe(sub); + } + for (const id of seeded) { + expect(await scalar(`select count(*)::int as n from auth.account where user_id = $1 and provider_id = 'whatsapp'`, [id])).toBe(0); + } + }); +}); + +describe('bans', () => { + it('a banned user → 403, every session deleted, and a JWT issued before the ban fails /api/account/me', async () => { + const bsuid = newBsuid(); + const token = await tokenOf(await exchange(bsuid)); + const { sub } = await claims(token); + expect((await send(service.publicUrl, '/api/account/me', { bearer: token })).status).toBe(200); + await service.client.query(`update auth.users set banned = true where id = $1`, [sub]); + + const res = await exchange(bsuid); + expect(res.status).toBe(403); + expect(res.text).toBe('{"message":"Forbidden"}'); + expect(await sessionCount(sub)).toBe(0); + expect((await send(service.publicUrl, '/api/account/me', { bearer: token })).status).toBe(401); + }); + + it('an expired ban is let through', async () => { + const bsuid = newBsuid(); + const { sub } = await claims(await tokenOf(await exchange(bsuid))); + await service.client.query( + `update auth.users set banned = true, ban_expires = now() - interval '1 minute' where id = $1`, + [sub] + ); + expect((await exchange(bsuid)).status).toBe(200); + }); +}); + +describe('the per-user budget', () => { + const { max } = limits.WHATSAPP_EXCHANGE_PER_USER; + const countOf = async (userId: string) => (await rateLimitRow(service, exchangeLimitKey(userId)))?.count ?? 0; + + it('is keyed by the user id, never the BSUID or the phone number', async () => { + const bsuid = newBsuid(); + const phone = newPhone(); + const { sub } = await claims(await tokenOf(await exchange(bsuid, phone))); + const { rows } = await service.client.query<{ key: string }>('select key from auth.rate_limit'); + const keys = rows.map((r) => r.key); + expect(keys).toContain(`sub:${sub}|/api/whatsapp/session`); + for (const k of keys) { + expect(k).not.toContain(bsuid); + expect(k).not.toContain(phone); + } + }); + + it(`answers 429 with an integer X-Retry-After after ${max}, and another user under the same key is unaffected`, async () => { + const bsuid = newBsuid(); + for (let i = 0; i < max; i++) expect((await exchange(bsuid)).status).toBe(200); + const res = await exchange(bsuid); + expect(res.status).toBe(429); + expect(res.text).toBe('{"message":"Too many requests"}'); + const retryAfter = res.headers.get('x-retry-after') ?? ''; + expect(retryAfter).toMatch(/^[1-9][0-9]*$/); + expect(Number(retryAfter)).toBeLessThanOrEqual(limits.WHATSAPP_EXCHANGE_PER_USER.window); + expect((await exchange(newBsuid())).status).toBe(200); + }); + + it('the counter survives a 403 (banned)', async () => { + const bsuid = newBsuid(); + const { sub } = await claims(await tokenOf(await exchange(bsuid))); + await service.client.query(`update auth.users set banned = true where id = $1`, [sub]); + const before = await countOf(sub); + expect((await exchange(bsuid)).status).toBe(403); + expect(await countOf(sub)).toBe(before + 1); + }); + + it('the counter survives a 500 (ineligible)', async () => { + const bsuid = newBsuid(); + const { sub } = await claims(await tokenOf(await exchange(bsuid))); + await service.client.query(`update auth.users set registered_via = 'web' where id = $1`, [sub]); + const before = await countOf(sub); + expect((await exchange(bsuid)).status).toBe(500); + expect(await countOf(sub)).toBe(before + 1); + }); +}); + +describe('failures that are not 401', () => { + it('an ineligible user → 500, one count-only alert per window, and a server warning with the user id', async () => { + nextAlertWindow(); + const sent = alertsOf('ineligible-user').length; + const subs: string[] = []; + for (const repair of [ + `update auth.users set role = 'admin' where id = $1`, + `update auth.users set email = 'someone-else@noreply.askansari.ai' where id = $1`, + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ('x', 'credential', $1, now())`, + ]) { + const bsuid = newBsuid(); + const { sub } = await claims(await tokenOf(await exchange(bsuid))); + await service.client.query(repair, [sub]); + const res = await exchange(bsuid); + expect(res.status).toBe(500); + expect(res.text).toBe(INTERNAL); + subs.push(sub); + } + const fired = alertsOf('ineligible-user').slice(sent); + expect(fired).toEqual([{ kind: 'ineligible-user', windowSeconds: limits.EXCHANGE_ALERT_WINDOW }]); + for (const sub of subs) { + expect(logged.some(([message, meta]) => message === INELIGIBLE_USER_MESSAGE && (meta as { userId?: string })?.userId === sub)).toBe(true); + } + }); + + it('the user deleted between resolve and lock → 500, and no session row', async () => { + // A trigger on the per-user budget's row, which is written after the + // user is resolved and before the lock, deletes the user in between. + await service.client.exec(` + create function auth.delete_exchange_user() returns trigger language plpgsql as $$ + begin + delete from auth.users where id = split_part(substr(new.key, 5), '|', 1)::uuid; + return new; + end $$; + create trigger delete_exchange_user after insert on auth.rate_limit for each row + when (new.key like 'sub:%|/api/whatsapp/session') execute function auth.delete_exchange_user(); + `); + try { + const bsuid = newBsuid(); + const before = await scalar('select count(*)::int as n from auth.session'); + const res = await exchange(bsuid); + expect(res.status).toBe(500); + expect(res.text).toBe(INTERNAL); + expect(await whatsappUserOf(bsuid)).toBeUndefined(); + expect(await scalar('select count(*)::int as n from auth.session')).toBe(before); + } finally { + await service.client.exec(` + drop trigger delete_exchange_user on auth.rate_limit; + drop function auth.delete_exchange_user(); + `); + } + }); + + it('the WhatsApp service user missing → 500 and one alert per window', async () => { + const lone = await startExchangeService(); + try { + nextAlertWindow(); + const sent = alertsOf('service-user-unresolvable').length; + const owner = await serviceUsers.createServiceUser(lone.db, 'whatsapp'); + const raw = await createKey({}, lone, owner.id); + await lone.client.query(`delete from auth.account where user_id = $1`, [owner.id]); + for (let i = 0; i < 3; i++) { + const res = await exchange(newBsuid(), undefined, { bearer: raw, on: lone }); + expect(res.status).toBe(500); + expect(res.text).toBe(INTERNAL); + } + expect(alertsOf('service-user-unresolvable').slice(sent)).toEqual([ + { kind: 'service-user-unresolvable', serviceUsers: 0, windowSeconds: limits.EXCHANGE_ALERT_WINDOW }, + ]); + expect(await scalar(`select count(*)::int as n from auth.account where provider_id = 'whatsapp'`, [], lone)).toBe(0); + } finally { + await lone.close(); + } + }); + + it('auth.apikey dropped under the running service → 500 for a valid and an unknown key once the check window has passed', async () => { + const outage = await startExchangeService(); + try { + const owner = await serviceUsers.createServiceUser(outage.db, 'whatsapp'); + const raw = await createKey({}, outage, owner.id); + const unknown = `ansari_wa_${'B'.repeat(64)}`; + // An unknown key while the relation is there: 401, and the check is cached. + expect((await exchange(newBsuid(), undefined, { bearer: unknown, on: outage })).status).toBe(401); + await outage.client.exec('drop table auth.apikey'); + // Inside the window, the stated residual: still 401. + expect((await exchange(newBsuid(), undefined, { bearer: unknown, on: outage })).status).toBe(401); + clock.advance(limits.APIKEY_RELATION_CHECK_TTL); + for (const bearer of [raw, unknown]) { + const res = await exchange(newBsuid(), undefined, { bearer, on: outage }); + expect(res.status).toBe(500); + expect(res.text).toBe(INTERNAL); + } + } finally { + await outage.close(); + } + }); +}); + +describe('the body (after a valid key)', () => { + const longBsuid = 'A'.repeat(257); + const cases: [string, string, string | null][] = [ + ['malformed JSON', '{"bsuid":', 'application/json'], + ['a text/plain body', JSON.stringify({ bsuid: 'US.1' }), 'text/plain'], + ['no Content-Type', JSON.stringify({ bsuid: 'US.1' }), null], + ['over the byte limit', JSON.stringify({ bsuid: 'US.1', pad: 'x'.repeat(limits.EXCHANGE_BODY_LIMIT) }), 'application/json'], + ['a JSON string', '"US.1"', 'application/json'], + ['a JSON array', '["US.1"]', 'application/json'], + ['bsuid missing', '{}', 'application/json'], + ['bsuid not a string', '{"bsuid":12345}', 'application/json'], + ['bsuid empty', '{"bsuid":""}', 'application/json'], + ['bsuid over 256 characters', JSON.stringify({ bsuid: longBsuid }), 'application/json'], + ['bsuid with a space', '{"bsuid":"US. 123"}', 'application/json'], + ['bsuid with a tab', '{"bsuid":"US.\\t123"}', 'application/json'], + ['bsuid with a control character', '{"bsuid":"US.\\u0001123"}', 'application/json'], + ['bsuid with a zero-width space', '{"bsuid":"US.\\u200b123"}', 'application/json'], + ['bsuid with a bidi override', '{"bsuid":"US.\\u202e123"}', 'application/json'], + ['bsuid with a zero-width joiner', '{"bsuid":"US.\\u200d123"}', 'application/json'], + ]; + + it.each(cases)('%s → the exact 400, no HTML, no body fragment', async (_name, body, contentType) => { + const before = await rowCounts(); + const res = await post(body, { contentType }); + expect(res.status).toBe(400); + expect(res.text).toBe(INVALID); + expect(res.headers.get('content-type')).toBe('application/json; charset=utf-8'); + expect(await rowCounts()).toEqual(before); + }); +}); + +describe('transport', () => { + const ORIGINS: [string, string | undefined][] = [ + ['the configured CORS_ORIGIN', BASE], + ['null', 'null'], + ['https://evil.example', 'https://evil.example'], + ['no Origin', undefined], + ]; + const noCors = (headers: Headers) => [...headers.keys()].filter((h) => h.startsWith('access-control-')); + + it.each(ORIGINS)('no Access-Control-* header on POST or OPTIONS (%s)', async (_name, origin) => { + const originHeader: Record = origin ? { origin } : {}; + const res = await exchange(newBsuid(), undefined, { headers: originHeader }); + expect(res.status).toBe(200); + expect(noCors(res.headers)).toEqual([]); + const refused = await exchange(newBsuid(), undefined, { headers: originHeader, bearer: null }); + expect(noCors(refused.headers)).toEqual([]); + const preflight = await fetch(`${service.publicUrl}${WHATSAPP_SESSION_PATH}`, { + method: 'OPTIONS', + headers: { ...originHeader, 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization,content-type' }, + }); + expect(preflight.status).toBe(404); + expect(noCors(preflight.headers)).toEqual([]); + }); + + it.each(['GET', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])('%s → 404 with an empty body and no Access-Control-* header', async (method) => { + const res = await fetch(`${service.publicUrl}${WHATSAPP_SESSION_PATH}`, { + method, + headers: { origin: BASE, authorization: `Bearer ${key}` }, + }); + expect(res.status).toBe(404); + expect(await res.text()).toBe(''); + expect(noCors(res.headers)).toEqual([]); + }); + + it('a sub-path → 404 with no Access-Control-* header', async () => { + const res = await exchange(newBsuid(), undefined, { path: `${WHATSAPP_SESSION_PATH}/extra`, headers: { origin: BASE } }); + expect(res.status).toBe(404); + expect(noCors(res.headers)).toEqual([]); + }); + + it('the exchange is mounted before corsFor', () => { + const app = createApp({ auth: service.auth, db: service.db }); + type Layer = { name: string; matchers: ((path: string) => unknown)[] }; + const stack = (app as unknown as { router: { stack: Layer[] } }).router.stack; + const exchangeAt = stack.findIndex((l) => l.name === 'router' && l.matchers.some((m) => m(WHATSAPP_SESSION_PATH))); + const corsAt = stack.findIndex((l) => l.name === 'corsMiddleware'); + expect(exchangeAt).toBeGreaterThanOrEqual(0); + expect(corsAt).toBeGreaterThan(exchangeAt); + }); + + it('is not reachable under /api/auth/*', async () => { + const before = await rowCounts(); + for (const path of ['/api/auth/whatsapp/session', '/api/auth/api/whatsapp/session']) { + const res = await exchange(newBsuid(), undefined, { path, headers: { 'x-real-ip': '198.51.100.7', origin: BASE } }); + expect(res.status).toBe(404); + } + expect(await rowCounts()).toEqual(before); + }); +}); + +describe('alerts', () => { + it('per-key window volume: one alert at WHATSAPP_EXCHANGE_KEY_ALERT, counts and the key id only', async () => { + const id = (await service.auth.api.createApiKey({ + body: { + userId: serviceUserId, + prefix: 'ansari_wa_', + name: 'volume', + permissions: WHATSAPP, + rateLimitEnabled: true, + rateLimitMax: 100, + rateLimitTimeWindow: 60_000, + }, + })); + remember(id.key); + const sent = alertsOf('key-window-volume').length; + // `{}` is a 400 after the key verifies: each one counts against the key. + for (let i = 0; i < limits.WHATSAPP_EXCHANGE_KEY_ALERT + 5; i++) { + expect((await post({}, { bearer: id.key })).status).toBe(400); + } + expect(alertsOf('key-window-volume').slice(sent)).toEqual([ + { + kind: 'key-window-volume', + keyId: id.id, + requests: limits.WHATSAPP_EXCHANGE_KEY_ALERT, + windowSeconds: 60, + threshold: limits.WHATSAPP_EXCHANGE_KEY_ALERT, + }, + ]); + }); + + it('per-key daily volume: one alert at WHATSAPP_EXCHANGE_DAILY_ALERT since the refill', async () => { + const created = await service.auth.api.createApiKey({ + body: { + userId: serviceUserId, + prefix: 'ansari_wa_', + name: 'daily', + permissions: WHATSAPP, + rateLimitEnabled: false, + remaining: 3000, + refillAmount: 3000, + refillInterval: limits.KEY_REFILL_INTERVAL, + }, + }); + remember(created.key); + await service.client.query(`update auth.apikey set remaining = $1, last_refill_at = now() where id = $2`, [ + 3000 - (limits.WHATSAPP_EXCHANGE_DAILY_ALERT - 1), + created.id, + ]); + const sent = alertsOf('key-daily-volume').length; + for (let i = 0; i < 3; i++) expect((await post({}, { bearer: created.key })).status).toBe(400); + expect(alertsOf('key-daily-volume').slice(sent)).toEqual([ + { + kind: 'key-daily-volume', + keyId: created.id, + requests: limits.WHATSAPP_EXCHANGE_DAILY_ALERT, + threshold: limits.WHATSAPP_EXCHANGE_DAILY_ALERT, + }, + ]); + }); + + it('401 volume: one alert per window at EXCHANGE_UNAUTHORIZED_ALERT, and again in the next window', async () => { + const { max } = limits.EXCHANGE_UNAUTHORIZED_ALERT; + for (let round = 0; round < 2; round++) { + nextAlertWindow(); + const sent = alertsOf('unauthorized-volume').length; + for (let i = 0; i < max + 5; i++) expect((await post({}, { bearer: null })).status).toBe(401); + expect(alertsOf('unauthorized-volume').slice(sent)).toEqual([ + { kind: 'unauthorized-volume', windowSeconds: limits.EXCHANGE_ALERT_WINDOW, threshold: max }, + ]); + } + }); + + it('every alert carries numbers, its kind, a refusal code or a key id, and nothing else', () => { + expect(alerts.length).toBeGreaterThan(0); + for (const alert of alerts) { + for (const [field, value] of Object.entries(alert)) { + if (field === 'kind') continue; + if (field === 'keyId') expect(value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + else if (field === 'code') expect(['USAGE_EXCEEDED', 'RATE_LIMITED']).toContain(value); + else expect(typeof value, field).toBe('number'); + } + } + }); +}); + +describe('the per-key daily alert across a day (fake clock)', () => { + it("fires once per refill period from the key row's counts, however the requests spread over hours", () => { + const sent: ExchangeAlert[] = []; + const day = new Clock(); + const tracker = createExchangeAlerts({ send: (a) => sent.push(a), now: day.now }); + const refilledAt = new Date(day.at).toISOString(); + const usage = (used: number, lastRefillAt: string) => ({ + id: 'key-1', + requestCount: 1, + rateLimitTimeWindow: 60_000, + remaining: 3000 - used, + refillAmount: 3000, + lastRefillAt, + createdAt: refilledAt, + }); + // A day's verifies, one every 30 minutes, the count growing past the threshold. + const step = Math.ceil(limits.WHATSAPP_EXCHANGE_DAILY_ALERT / 40); + for (let i = 1; i <= 48; i++) { + day.advance(30 * 60_000); + tracker.keyVerified(usage(i * step, refilledAt)); + } + expect(sent.filter((a) => a.kind === 'key-daily-volume')).toHaveLength(1); + // The next refill period starts a new count and may fire again. + const nextRefill = new Date(day.at).toISOString(); + tracker.keyVerified(usage(limits.WHATSAPP_EXCHANGE_DAILY_ALERT, nextRefill)); + tracker.keyVerified(usage(limits.WHATSAPP_EXCHANGE_DAILY_ALERT + 1, nextRefill)); + expect(sent.filter((a) => a.kind === 'key-daily-volume')).toHaveLength(2); + }); +}); + +describe('the Sentry scrub', () => { + it('an event from the exchange keeps the method, path and safe headers only', () => { + const scrubbed = scrubRequestEvent({ + message: 'x', + request: { + method: 'POST', + url: `https://auth.example${WHATSAPP_SESSION_PATH}?a=1`, + headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + data: '{"bsuid":"US.1","phone_number":"15551234567"}', + query_string: 'a=1', + cookies: { a: 'b' }, + }, + }); + expect(scrubbed.request).toEqual({ + method: 'POST', + url: `https://auth.example${WHATSAPP_SESSION_PATH}`, + headers: { 'content-type': 'application/json' }, + }); + }); + + // Since #334 the scrub is unconditional: every path, not only the exchange. + it('an event from another path is scrubbed the same way', () => { + const request = { + method: 'GET', + url: 'https://auth.example/api/account/me?x=1', + headers: { authorization: 'Bearer a.jwt.here', cookie: 'c=1', 'user-agent': 'ua' }, + data: '{"email":"a@example.com"}', + }; + expect(scrubRequestEvent({ request: { ...request } }).request).toEqual({ + method: 'GET', + url: 'https://auth.example/api/account/me', + headers: { 'user-agent': 'ua' }, + }); + }); +}); + +describe('logs (every case above)', () => { + it('carry no raw key, key hash, session token, BSUID, phone number or driver error object', async () => { + const { rows: sessions } = await service.client.query<{ token: string }>('select token from auth.session'); + const { rows: keys } = await service.client.query<{ key: string }>('select key from auth.apikey'); + const needles = [...secrets, ...sessions.map((s) => s.token), ...keys.map((k) => k.key)]; + expect(logged.length).toBeGreaterThan(0); + const text = logged.map((args) => args.map((a) => inspect(a, { depth: 8 })).join(' ')).join('\n'); + for (const needle of needles) expect(text).not.toContain(needle); + for (const args of logged) for (const arg of args) expect(arg).not.toBeInstanceOf(Error); + }); +}); diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md new file mode 100644 index 00000000..666f42f5 --- /dev/null +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -0,0 +1,233 @@ +# Plan: WhatsApp support in the monorepo + +**Specification**: [codev/specs/321-rfc-whatsapp-support-in-the-mo.md](../specs/321-rfc-whatsapp-support-in-the-mo.md) + +## Executive Summary + +Follow the spec's recommended Approach 1: import the existing Python WhatsApp service into the monorepo as a separate app (`legacy/whatsapp`), then replace its legacy user handling with the `apps/auth` token exchange, then add the one-thread-per-user lifecycle, then finish environment behaviour (maintenance mode, `!d ` filter) and deployment docs. The Baked Decisions in the spec are fixed and not re-opened here. The `/whatsapp` contract is taken as given (`POST /api/whatsapp/session`, `AUTH_WHATSAPP_API_KEY`, `{ bsuid }` in, `{ token }` out). + +Testing strategy, used by every phase: +1. **Automated:** pytest with mock Meta, mock `apps/auth` and mock `apps/api` clients (the legacy base/real/mock service pattern). No network, no Meta. +2. **Local end to end:** the service runs locally, exposed to Meta through a zrok tunnel, with test messages prefixed `!d `. The `zrok.exe` binary is used for this (see "Local testing with zrok" below). +3. **Staging:** a final smoke test only, after the phases are merged and deployed. + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "import_service", "title": "Import the Python service as is"}, + {"id": "auth_token_exchange", "title": "Replace legacy user handling with the apps/auth token exchange"}, + {"id": "thread_lifecycle", "title": "One thread per user with expiry"}, + {"id": "environments_and_docs", "title": "Maintenance mode, staging filter, local testing and docs"} + ] +} +``` + +## Phase Breakdown + +### Phase 1: Import the Python service as is + +**Dependencies**: None + +#### Objective + +Bring `ansari-whatsapp` into the monorepo as `legacy/whatsapp` with minimal changes, so it builds, lints and passes its existing tests there. Behaviour is unchanged at this point. + +#### Files to Create / Modify + +- `legacy/whatsapp/` (source, `pyproject.toml`, `uv.lock`, `pytest.ini`, `Dockerfile`, tests, `.env.example`), copied from the legacy repo without its logs, `tmp/` files, personal scripts, the `zrok.exe` binary and generated artifacts. +- Monorepo wiring only where needed (workspace and CI entries), following how other apps are registered. + +#### Deliverables + +- [ ] `legacy/whatsapp` present and runnable with `uv` and `ruff`. +- [ ] Legacy tests pass with `MOCK_META_API=True` and `MOCK_ANSARI_CLIENT=True`. +- [ ] `.env.example` contains no secrets. +- [ ] Tests for this phase (the imported suite). + +#### Acceptance Criteria + +- [ ] `uv sync`, `ruff check`, `ruff format --check` and `pytest tests/ -v` pass in `legacy/whatsapp`. +- [ ] Existing monorepo build and tests are unaffected. + +#### Test Plan + +Run the imported test suite in mock mode. Manually start the service and call the health check (`GET /`) and the webhook verification handshake (`GET /whatsapp/v2`). + +### Phase 2: Replace legacy user handling with the apps/auth token exchange + +**Dependencies**: Phase 1 + +#### Objective + +Identify users by BSUID only. The service gets a JWT from `apps/auth` and calls `apps/api` with it. No phone number or BSUID is sent to `apps/api` or stored by this app. + +#### Files to Create / Modify + +- In `legacy/whatsapp`: a new auth client (base, real and mock, like the other services) behind the service provider; the webhook parser and conversation manager updated to key on BSUID and drop phone-number registration and the phone-hidden notice; the Ansari client updated to call `apps/api` with the JWT instead of legacy registration endpoints. +- New settings: auth base URL and `AUTH_WHATSAPP_API_KEY`, validated in config and listed in `.env.example`. +- Confirm in `apps/api` which existing endpoints the service needs (chat streaming, thread create or read, history) and use them as they are. If a needed endpoint is missing, record it as a finding instead of widening this phase. + +#### Deliverables + +- [ ] Auth client with mock implementation. +- [ ] Phone-visible and phone-hidden payloads both handled; the BSUID (`user_id` in the payload) is the only identity used. +- [ ] Failure of the token exchange sends the user a failure notice, is logged, and Meta still gets "OK". +- [ ] Logs and errors contain no phone numbers. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] Same BSUID yields a token request each time with the same input; a different BSUID is a different user (verified through the mock). +- [ ] No outgoing call to `apps/auth` or `apps/api` carries a phone number (asserted in tests). +- [ ] Streaming answers are still aggregated and split for WhatsApp as before. + +#### Test Plan + +Unit tests for the auth client (success, 400, 401, 500, timeout) and for webhook parsing of both user types. Service-level tests with all three mocks for the full message flow. Local end-to-end run with the zrok tunnel and `!d ` once `apps/auth` offers the endpoint; until then, only the mock-based tests apply. + +### Phase 3: One thread per user with expiry + +**Dependencies**: Phase 2 + +#### Objective + +Each user has exactly one thread. If the last activity is older than the global expiry (3 hours), the old thread and its messages are deleted and a new thread starts. + +#### Files to Create / Modify + +- In `legacy/whatsapp`: the conversation manager's thread logic and the Ansari client's thread calls; expiry setting in config (global, default 3 hours). +- `apps/api` only if the existing endpoints cannot read the thread's last activity or delete a thread with its messages; any such change is kept minimal and noted in the review. + +#### Deliverables + +- [ ] Expired-thread replacement; non-expired threads reused. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] Message within the expiry reuses the thread and keeps context. +- [ ] Message after the expiry deletes the old thread and messages and creates a new thread. +- [ ] A user normally has exactly one thread. Two messages arriving back to back right after the expiry could each create one; this is a known, accepted corner case, documented in `_resolve_thread` (owner decision: no lock, and an occasional manual sweep keeps only each WhatsApp user's newest thread). + +#### Test Plan + +Unit tests with a controllable clock for the boundary (just inside, just outside the expiry) and the first-ever message. Local end-to-end check with a short expiry value set through config. + +### Phase 4: Maintenance mode, staging filter, local testing and docs + +**Dependencies**: Phase 3 + +#### Objective + +Finish environment behaviour and make the service testable and deployable by others. + +#### Files to Create / Modify + +- In `legacy/whatsapp`: rename the maintenance flag `WHATSAPP_UNDER_MAINTENANCE` to `WA_MAINTENANCE_MODE` (owner decision): it is read from this service's own environment and is independent of `apps/api`'s `MAINTENANCE_MODE`; keep the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). +- Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. +- Findings from the Phase 2 review to resolve here: (a) apps/api reports a failed or empty answer as an inline `Error: ...` at the end of a 200 stream, which the client currently relays to the user unlogged, so detect it, log it and send a failure notice; (b) deploy blocker to confirm before the staging smoke test: the service now requires `from_user_id` in every webhook, and sending to a BSUID (`recipient`) is untested against Meta's live API (Meta's docs disagree on when it is supported), so check the webhook Graph API version and test a real send; (c) `README.md` still documents the six deleted legacy endpoints. +- Findings from the Phase 3 review to resolve here: failures of the thread list, create and delete calls are logged but not captured in Sentry, unlike message-processing failures, so add that with the logging work. (The review's single-replica note no longer applies: the per-user lock was removed by owner decision.) +- Issues to raise with the `apps/api` owner when the PR is opened, not to fix in this work: (a) the chat routes hard-code `source: 'web'` on every message they save, so WhatsApp messages are labelled `web` even though their thread is `whatsapp`; (b) the answer stream reports a failure as trailing `Error:` text after a 200 response, with no explicit marker (see the `Error:` item above). +- Housekeeping to document: an occasional manual sweep deleting all but the newest thread of each WhatsApp user (threads with source `whatsapp`). +- Deploy-path items found in the Phase 1 review: bump the uv version in the `Dockerfile` (it pins 0.5.11 against a newer lockfile format) and make its second `uv sync` use `--frozen`; decide whether to keep `/sentry-debug` and `clean_logs.sh`; ask an admin to make the `whatsapp` CI job a required check on `develop`. + +#### Deliverables + +- [x] Maintenance flag `WA_MAINTENANCE_MODE`, with tests. +- [x] `!d ` filter kept, with tests (ignored in staging, processed otherwise). +- [x] README covering setup, tests, and local testing with zrok. +- [x] Staging smoke-test checklist (in `legacy/whatsapp/README.md`). + +#### Phase 4 outcome (what changed from the notes above) + +- The client now uses the `/chat` stream, which reports failures as an explicit `error` event, so the inline `Error:` detection in item (a) is no longer needed; a failed, cut-off or malformed stream is a failure the user is told about. +- Logs carry only `request_id` (no user id, so `get_subject` was removed) and are one JSON line per entry on stdout, the shape Railway indexes. +- Thread-list, create, delete, message-processing and sign-in failures are captured in Sentry. A backend timeout is captured as a message-processing failure (the real client wraps timeouts into `MessageProcessingError`); there is no separate timeout case. +- `/sentry-debug` is kept but registered only outside production; `clean_logs.sh` is removed (logs go to stdout). +- The app reads `PORT` (default 8001). +- Dockerfile: uv 0.9.13, `uv sync --frozen`, and paths relative to the repo root (the build context RELEASE.md prescribes). `legacy/whatsapp/railway.toml` records the Railway settings; Railway does not read it. + +#### Real-service run (owner requirement: Phase 4 is not complete on mocks alone) + +Done 2026-10-26, local, no Docker: real `apps/auth` (new empty `ansari_auth` database) and real `apps/api` (the local app database, migrated 8 to 11 with existing rows intact, Gemini key from `apps/api/.env`), the WhatsApp service with `MOCK_ANSARI_CLIENT=False`, `MOCK_AUTH_CLIENT=True` plus `MOCK_AUTH_TOKEN` (a real JWT from a throwaway user) and `MOCK_META_API=True`. + +- A correctly signed webhook (BSUID only) was accepted, the real `apps/api` accepted the JWT, a `whatsapp` thread was created, the real `/chat` stream returned an answer (about 9 seconds) and it was handed to the (mock) Meta sender. +- A second message from the same BSUID reused the same thread: one thread, four saved messages (user, assistant, user, assistant). +- Still real-only gaps: the BSUID-to-JWT exchange (`apps/auth` has no `/whatsapp` route yet; mock by owner decision, to confirm with Amr). + +#### Real-Meta run (2026-10-26) + +No Meta test number could be claimed (see the PR notes below), so the run used Meta's own pieces where it could and the production number for one send. + +- **Webhooks from Meta, through zrok:** the Test app's callback URL (`https://zrok0ansari0test.share.zrok.io/whatsapp/v2`) passed Meta's verification handshake; Meta's dashboard sample webhooks (v26.0) reached the service with a valid signature. They were ignored because Meta's samples carry a fake phone number ID; equivalent signed payloads (username with phone hidden, and with phone present) were then sent through the public URL with the sample ID set locally, and each was identified by BSUID, got a thread in the real `apps/api` and an answer. Meta's "not opted in" sample was not replayed. +- **Send to a BSUID:** one text was sent from the production number to the developer's own BSUID (`recipient`, no `to`) with a direct HTTP call using the same endpoint, token and body shape as `MetaApiServiceReal`. Meta answered 200 with the BSUID echoed as `user_id`, and delivery and read status webhooks followed. The developer confirming it appeared on the phone is still to be recorded. The service class itself was not used for this send. +- **How the BSUID was obtained:** a temporary log line for `from_user_id` was added to the legacy service for about an hour and is now commented out; the value lives only in the git-ignored `legacy/whatsapp/.env` as `REAL_TEST_BSUID`. +- **Fixes found on the way:** the verification handler logged the verify token in local mode (removed, with a test); the app ID in the legacy `.env` belonged to a different Meta app than the app secret (a local config error, not a code change). +- **Not covered:** a real phone's message arriving at the new service (the Test app has no number), and the Docker image. + +#### Open at the end of Phase 4 (not verified) + +- The Docker image has never been built or run (no Docker daemon was available). +- A real phone's webhook reaching the new service is untested (sample webhooks and one production-number send were verified, see above). Meta's own samples show `from_user_id` present, but that is a sample, not a live payload. +- The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed and was not exercised (a real `apps/api` and the real `apps/auth` were run, see above). +- The `whatsapp` CI job has not run on a GitHub runner. Making it a required check on `develop` needs a repo admin; mention how in the PR description rather than doing it here. + +#### Phase 4 review (Claude, iteration 1): COMMENT + +All findings were checked against the code. Fixed: the README and `tests/README.md` no longer claim nothing ran against live Meta or `apps/api` (they now say what was and was not done); the unreachable `httpx.TimeoutException` branch and its import were removed; a missing new thread id is now captured in Sentry like the other creation failures; Sentry captures for thread-list, delete, create and message-processing failures each have a test (each fails if its capture call is removed); the JSON-log decision became `should_use_json_logs()` with parametrized cases (the old test passed without its CI clause); `legacy/whatsapp/.python-version` pins 3.13, the version the Docker image uses, and the suite passes on it; the dead `META_WABA_ID` block and the legacy `ngrok` host were removed from `.env.example`. + +Decided, no change: the maintenance check runs before the `!d ` filter, so a staging instance in maintenance mode answers a developer's `!d ` message with the maintenance notice. This matches the legacy order and is harmless, so it stays. + +#### Notes for the PR description (do not lose these) + +1. **Call to action (Waleed):** a new WhatsApp phone number is needed for the staging and test environments to share. Reason: the developer cannot claim a Meta test number ("An error occurred while creating the test account. Please try again."), the account has one live production number, and Meta's free tier allows two business numbers, with a test number counting as one. The failure is widely reported and Meta has not said it is working on a fix, as far as the threads below show. Forum threads (Meta for Developers community unless noted; each was seen listed in search results, and only the titles and snippets were read, not the full threads): + - https://developers.facebook.com/community/threads/1725780401801558/ (test number provisioning fails although the test WABA exists) + - https://developers.facebook.com/community/threads/899305532556240/ (error while creating a WhatsApp Business test account) + - https://developers.facebook.com/community/threads/1789721235531963/ (a verified business cannot claim a test number) + - https://developers.facebook.com/community/threads/2182066965706958/ ("No phone numbers available" when claiming) + - https://developers.facebook.com/community/threads/1379359970388767/ ("get new test number" fails) + - https://developers.facebook.com/community/threads/1342448903816361/ (asked 8 Dec 2025, the same error) + - https://developers.facebook.com/community/threads/568920538895906/ (asked 23 Sep 2024, cannot create a test number) + - https://developers.facebook.com/community/threads/1246890150314692/ (cannot claim a test number, for several days) + - https://developers.facebook.com/community/threads/849020947139819/ (no test number after adding the WhatsApp product) + - https://developers.facebook.com/community/threads/2128147975242023 and https://developers.facebook.com/community/threads/1694270385568451 and https://developers.facebook.com/community/threads/2981069012252252 (same error) + - https://stackoverflow.com/questions/79475543/whatsapp-cloud-api-test-number-not-active-after-generation (a related Stack Overflow question) + - https://www.reddit.com/r/WhatsappBusinessAPI/comments/1ni3dxr/whatsapp_api_current_issue_invalid_test_phone/ (Reddit, "apparently some others in the meta developer community") + The dated threads (Sep 2024, Dec 2025) and the many recent ones show the problem is not new. Before the PR is opened, re-open a few of these and confirm they still say what is written here, and whether anyone from Meta has answered. +2. **Alternative if no new number is created (developer's idea, not built):** all three environments (production, staging, local) share the one production number and each answers only its own messages: no prefix means production, a `!s ` prefix means staging, a `!d ` prefix means local. Each environment ignores messages meant for the others, so the production service must be changed first to ignore the prefixed ones. Downsides to state: every environment's app receives all messages for the number, including real users' (a privacy cost for a laptop), the replies come from the production number, and a missing rule in one environment means a double or wrong answer. Today only the `!d ` rule exists, in staging. +3. **BSUID logging:** the legacy service has a commented-out, opt-in log line for reading a developer's own BSUID; it must stay commented out in production. + +#### Acceptance Criteria + +- [ ] With the flag on, users get the notice and no chat call is made. +- [ ] In staging mode, `!d `-prefixed messages are ignored and others processed. +- [ ] A new developer can run the service locally and receive a test message following only the README. + +#### Test Plan + +Unit tests for both behaviours. Manual: local run through zrok with `!d `, then staging smoke test (a normal message answered, a `!d ` message ignored by staging and answered locally). + +## Local testing with zrok + +- The legacy flow stays: `zrok reserve public localhost:8001 -n `, with the Meta test app's webhook URL pointing at the reserved share. +- A copy of `zrok.exe` has been placed in the worktree's `tmp/` folder, which is git-ignored, so the command runs there (`tmp\zrok.exe`) and the 86 MB binary never gets committed. The original stays in `ansari-whatsapp`. +- Cleaner alternatives, not used by default: install zrok once on the PATH (for example through a package manager) so no copy is needed per worktree; or keep one shared tools folder outside the repos and put it on the PATH. +- Local runs use the Meta test app's credentials and the shared test number, so messages meant for local must start with `!d ` to keep staging from answering them. + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| `apps/auth` endpoint is not available when Phase 2 is built | Medium | Medium | Mock auth client; local end to end deferred until it exists | +| `apps/api` lacks a thread read or delete endpoint needed in Phase 3 | Medium | Medium | Check early in Phase 2; add the smallest change and note it | +| Concurrent messages create two threads for one user | Low | Low | Accepted without a lock (owner decision); documented in `_resolve_thread`; occasional manual sweep keeps only the newest thread | +| Imported code carries secrets, logs or personal files | Low | High | Copy only source, tests and config examples; review the diff before committing | +| Shared test number makes local and staging answer the same message | Medium | Low | `!d ` filter kept and tested | + +## Documentation Updates + +- New `legacy/whatsapp/README.md`. +- Deployment and environment notes for the WhatsApp service in the monorepo docs. +- `codev/resources` arch notes only if the review finds a lasting system-shape fact. diff --git a/codev/plans/324-native-google-apple-auth.md b/codev/plans/324-native-google-apple-auth.md new file mode 100644 index 00000000..387c4904 --- /dev/null +++ b/codev/plans/324-native-google-apple-auth.md @@ -0,0 +1,303 @@ +# Plan: Native Google and Apple Authentication (Mobile & Web) + +**Specification**: [codev/specs/324-native-google-apple-auth.md](../specs/324-native-google-apple-auth.md) + +--- + +## Executive Summary + +This plan implements the proposed specification for native Google and Apple authentication across mobile (`apps/frontend`) and web, backed by `packages/auth` and `apps/auth`. + +Key architectural principles: +- **Universal ID-Token Architecture (Cookie-Free)**: The frontend client (`apps/frontend/lib/auth/api.ts`) operates strictly cookie-free (`credentials: 'omit'`). All platforms (iOS, Android, and Web) acquire an ID token directly on the client and submit it to `POST /api/auth/sign-in/social`, receiving the `sessionToken` in the JSON response body. +- **Native Modules on Mobile (Non-Negotiable)**: On native iOS and Android, authentication uses native OS dialogs. Apple Sign-In uses `expo-apple-authentication` (iOS). Google Sign-In uses `react-native-nitro-google-signin` (powered by Nitro Modules / JSI, integrating Android Credential Manager and native iOS Google Sign-In SDK). Web redirects (`expo-web-browser` / Custom Tabs) on native mobile are prohibited. +- **Web Social Auth (In-Page ID Tokens)**: Web desktop and mobile browsers avoid redirect callbacks: + - **Google**: Google Identity Services (GIS) issues ID tokens in-page through its official rendered button or One Tap. + - **Apple**: Sign in with Apple JS (`AppleID.auth.signIn({ usePopup: true })`) issues ID tokens in-page via popup. +- **Platform Button Matrix**: + - **iOS**: Renders both "Continue with Apple" and "Continue with Google" (Apple first, per Apple HIG). + - **Android**: Renders **Google only**; the Apple button is hidden (`Platform.OS !== 'android'`). + - **Web**: Renders both (Google via GIS rendered button; Apple via popup button). +- **Cryptographic Nonce**: Every sign-in attempt generates a fresh cryptographic nonce passed to the provider and verified by `apps/auth` to prevent token replay attacks. +- **Two-Token Protocol Parity**: Third-party logins produce a Better Auth `sessionToken` on `apps/auth`. The frontend exchanges this for a 15-minute EdDSA JWT via `GET /api/auth/token` for stateless `apps/api` access, preserving 401 single-flight token refresh. +- **Zero Schema Migrations**: The `auth.account` table in `packages/auth/src/schema.ts` already contains all necessary columns (`provider_id`, `account_id`, `id_token`, `access_token`, etc.) with `unique(user_id, provider_id)`. +- **Account Linking & Collision Safety**: Verified emails automatically link provider accounts under existing `auth.users` rows without creating duplicates or disrupting password authentication. + +--- + +## Phases (Machine Readable) + +```json +{ + "phases": [ + { + "id": "phase_1", + "title": "Backend environment, Better Auth social providers, multi-audience, and nonce-verified ID token exchange" + }, + { + "id": "phase_2", + "title": "Frontend native module dependencies and EAS prebuild configuration" + }, + { + "id": "phase_3", + "title": "Frontend universal auth client layer, nonce generation, and ID token exchange" + }, + { + "id": "phase_4", + "title": "AuthSheet UI wiring, platform button matrix, cancellation UX, and real-device check" + }, + { + "id": "phase_5", + "title": "Automated tests, contract verification, and workspace verification" + } + ] +} +``` + +--- + +## Phase Breakdown + +### Phase 1: Backend Environment, Better Auth Social Providers, Multi-Audience, and Nonce-Verified ID Token Exchange + +**Dependencies**: None + +#### Objective +Enable Google and Apple social providers in `packages/auth` with native/web ID token verification, multi-audience support (iOS bundle ID `chat.ansari.app` and Web Services ID `APPLE_CLIENT_ID`), cryptographic nonce verification, and account linking. + +#### Files to Create / Modify +- `packages/auth/src/env.ts`: + - Add optional OAuth schema keys to `envSchema`: + - `GOOGLE_CLIENT_ID`: Web Client ID (audience for Android, iOS, and Web ID tokens). + - `GOOGLE_CLIENT_SECRET`: Client secret for Google provider configuration. + - `APPLE_CLIENT_ID`: Services ID for Web sign-in (e.g. `ai.askansari.web`). + - `APPLE_CLIENT_SECRET`: Client secret for Apple provider configuration. + - `APPLE_BUNDLE_ID`: Defaults to `'chat.ansari.app'` (audience for iOS native Apple ID tokens). + - `APPLE_TEAM_ID` & `APPLE_KEY_ID`: For Apple configuration. +- `apps/auth/.env.example`: Document the new Google and Apple environment variables. +- `turbo.json`: Add new environment variables to `globalEnv`. +- `packages/auth/src/index.ts`: + - Configure `socialProviders` in `createAuth()`: + - `google`: `clientId: env.GOOGLE_CLIENT_ID`, `clientSecret: env.GOOGLE_CLIENT_SECRET`. + - `apple`: `clientId: env.APPLE_CLIENT_ID`, `clientSecret: env.APPLE_CLIENT_SECRET`. + - ID Token Verification Configuration (`verifyProviderIdToken`): + - Google: validates signature against Google certs (`https://www.googleapis.com/oauth2/v3/certs`), validates `iss`, validates `aud === env.GOOGLE_CLIENT_ID`, and validates `payload.nonce === nonce`. + - Apple: validates signature against Apple JWKS (`https://appleid.apple.com/auth/keys`), validates `iss === 'https://appleid.apple.com'`, validates that `aud` matches **either** `env.APPLE_BUNDLE_ID` (`chat.ansari.app`) **or** `env.APPLE_CLIENT_ID` (Web Services ID), and validates `payload.nonce === nonce`. + - Ensure `POST /api/auth/sign-in/social` handles `{ provider, idToken: { token, nonce, user } }`: + - Maps claims (`email`, `emailVerified`, `name`). + - On first Apple sign-in, persists `name` (`firstName`, `lastName`) passed via `idToken.user.name` into `auth.users`. + - Handles Apple Private Relay emails (`@privaterelay.appleid.com`). + - Handles account linking when `auth.users` row already exists with the same verified email. + - User creation hook (`databaseHooks.user.create.before`) records `registered_via` from `X-Ansari-Client` header. +- `packages/auth/tests/config/social-auth.test.ts` (new): + - Unit tests using PGlite verifying: + - Google ID token verification and audience matching. + - Apple ID token verification with iOS bundle ID audience (`chat.ansari.app`). + - Apple ID token verification with Web Services ID audience (`APPLE_CLIENT_ID`). + - Nonce verification (fails closed with 401 `INVALID_TOKEN` if nonce does not match). + - Account linking: existing email user gains provider account link without duplicate user row. + - Error handling: expired token (401), invalid signature (401), missing email claim (401). + +#### Deliverables +- [ ] `packages/auth/src/env.ts` validates optional Google and Apple OAuth credentials. +- [ ] `packages/auth/src/index.ts` configures `socialProviders` with native/web `idToken` verification, multi-audience Apple support, and nonce checking. +- [ ] First sign-in Apple name capture and Private Relay email persistence implemented. +- [ ] Account linking tested and verified in PGlite. + +#### Acceptance Criteria +- [ ] Unset OAuth env vars do not prevent service startup or break existing email/password auth. +- [ ] Valid Google ID token sent to `POST /api/auth/sign-in/social` returns 200 with `{ token, user }`. +- [ ] Valid Apple ID token with `aud: 'chat.ansari.app'` returns 200 with `{ token, user }`. +- [ ] Valid Apple ID token with `aud: env.APPLE_CLIENT_ID` returns 200 with `{ token, user }`. +- [ ] ID token with mismatched or missing nonce returns 401 `INVALID_TOKEN`. +- [ ] Invalid/expired ID token returns 401 `INVALID_TOKEN`. + +--- + +### Phase 2: Frontend Native Module Dependencies and EAS Prebuild Configuration + +**Dependencies**: Phase 1 + +#### Objective +Install and configure `expo-apple-authentication` and `react-native-nitro-google-signin` in `apps/frontend`, updating `app.json` config plugins and native entitlements, while keeping Google services files optional. + +#### Files to Create / Modify +- `apps/frontend/package.json`: + - Add `expo-apple-authentication` (`~57.0.x`). + - Add `react-native-nitro-google-signin` and peer dependency `react-native-nitro-modules`. +- `apps/frontend/app.json`: + - Add `"expo-apple-authentication"` to `expo.plugins`. + - Add iOS entitlement: `"ios.entitlements": { "com.apple.developer.applesignin": ["Default"] }` (or via config plugin). + - Add `"react-native-nitro-google-signin"` to `expo.plugins`. + - Add reversed client ID URL scheme under `ios.infoPlist.CFBundleURLTypes` for Google Sign-In redirect handling. + - Note: `google-services.json` and `GoogleService-Info.plist` are **optional** because `webClientId` is explicitly passed in configuration. +- `apps/frontend/app.config.js` or `app.json`: + - Declare `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` and `EXPO_PUBLIC_APPLE_CLIENT_ID`. + +#### Deliverables +- [ ] `pnpm install` succeeds across the workspace. +- [ ] `npx expo config --type public` in `apps/frontend` resolves without error. +- [ ] Web export (`pnpm --filter ansari-frontend build:web`) continues to compile cleanly. + +#### Acceptance Criteria +- [ ] Workspace typecheck (`pnpm typecheck`) and lint (`pnpm lint`) pass with 0 errors. +- [ ] Native module imports do not crash web bundling (`react-native-web` compatibility). + +--- + +### Phase 3: Frontend Universal Auth Client Layer, Nonce Generation, and ID Token Exchange + +**Dependencies**: Phase 1, Phase 2 + +#### Objective +Implement the universal (cookie-free) ID-token sign-in adapters for Apple and Google across iOS, Android, and Web, including per-attempt cryptographic nonce generation, connecting them to `lib/auth/api.ts` and `lib/auth/context.tsx`. + +#### Files to Create / Modify +- `apps/frontend/lib/auth/crypto.ts` (new): + - Helper to generate a cryptographically random nonce string using `crypto.getRandomValues()` (Web) or `expo-crypto` (native), and compute SHA-256 where required. +- `apps/frontend/lib/auth/nativeApple.ts` & `apps/frontend/lib/auth/nativeApple.web.ts`: + - Platform-split Apple helper: + - Native (`.ts` / `.native.ts`): checks `AppleAuthentication.isAvailableAsync()`, generates nonce $N$, invokes `AppleAuthentication.signInAsync({ requestedScopes: [FULL_NAME, EMAIL], nonce: hash(N) })`, extracts `identityToken`, `fullName`, and calls `authApi.signInWithIdToken('apple', identityToken, N, user)`. + - Web (`.web.ts`): loads Apple JS script, initializes `AppleID.auth.init({ clientId, scope, redirectURI, usePopup: true, nonce: N })`, calls `AppleID.auth.signIn()`, extracts `id_token` and first-sign-in `user` name, and calls `authApi.signInWithIdToken('apple', id_token, N, user)`. +- `apps/frontend/lib/auth/nativeGoogle.ts` & `apps/frontend/lib/auth/nativeGoogle.web.ts`: + - Platform-split Google helper: + - Native: configures `GoogleOneTapSignIn.configure({ webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID })`, generates nonce $N$, invokes `GoogleOneTapSignIn.signIn({ nonce: N })`, extracts `idToken`, and calls `authApi.signInWithIdToken('google', idToken, N)`. + - Web (`.web.ts`): initializes Google Identity Services (`google.accounts.id.initialize({ client_id, nonce: N, callback })`), renders Google button into container, receives `response.credential`, and calls `authApi.signInWithIdToken('google', credential, N)`. +- `apps/frontend/lib/auth/api.ts`: + - Add `signInWithIdToken(provider: 'apple' | 'google', idToken: string, nonce: string, user?: { name?: { firstName?: string; lastName?: string } })`: + - Sends `POST /api/auth/sign-in/social` with payload: + ```json + { + "provider": provider, + "idToken": { + "token": idToken, + "nonce": nonce, + "user": user + } + } + ``` + - Uses existing `customFetch` (`credentials: 'omit'`). + - Returns `{ token: string, user: AuthUser }` from JSON body. +- `apps/frontend/lib/auth/context.tsx`: + - Expose `signInWithApple()` and `signInWithGoogle()` on `AuthContext`. + - When provider returns `sessionToken`, invoke `startSession({ sessionToken, user })` which: + 1. Calls `authApi.mintJwt(sessionToken)` (`GET /api/auth/token`). + 2. Persists `accessToken` and `sessionToken` in `expo-secure-store` / `localStorage`. + 3. Clears React Query cache and sets user state. + +#### Deliverables +- [ ] Nonce generation helper implemented. +- [ ] Platform-split Apple and Google auth services implemented (iOS native, Android native, Web GIS/popup). +- [ ] `lib/auth/api.ts` implements `signInWithIdToken` sending token and nonce in JSON body. +- [ ] `lib/auth/context.tsx` provides unified social sign-in methods adhering to Two-Token Protocol. + +#### Acceptance Criteria +- [ ] Calling `signInWithIdToken` against `apps/auth` establishes a valid session and mints an EdDSA JWT for `apps/api`. +- [ ] 401 token refresh functions identically to email/password sessions. + +--- + +### Phase 4: AuthSheet UI Wiring, Platform Button Matrix, Cancellation UX, and Real-Device Check + +**Dependencies**: Phase 3 + +#### Objective +Wire social buttons in `AuthSheet.tsx` to active authentication handlers, apply platform button visibility rules (hide Apple on Android), handle cancellation cleanly, and verify on physical devices. + +#### Files to Create / Modify +- `apps/frontend/components/AuthSheet.tsx`: + - Platform button filtering: + - Render Apple button only on iOS and Web (`Platform.OS !== 'android'`). + - On Web: render official Google Identity Services button container alongside Apple button. + - On Mobile (iOS/Android): render `ProviderButton` for Google and Apple (iOS only). + - Remove `unavailable` local state stub and message. + - Wire Apple `onPress` to `handleAppleSignIn`. + - Wire Google `onPress` to `handleGoogleSignIn`. + - Handle user cancellation: + - Apple: ignore `ERR_REQUEST_CANCELED` (code 1001) or popup closure without displaying an error. + - Google: ignore user cancellation without displaying an error. + - On network or verification failure, display inline error banner using existing error styling. + - On success, close the sheet via `closeAuthSheet()`. +- `apps/frontend/components/AuthSheet.test.tsx`: + - Update unit tests: remove tests expecting "not available yet" stub notice. + - Add test asserting that when `Platform.OS === 'android'`, the Apple button is **not rendered**. + - Add test asserting that when `Platform.OS === 'ios'`, both Apple and Google buttons are rendered. + - Add tests verifying provider button click triggers `signInWithApple` / `signInWithGoogle`. + - Add tests verifying cancellation does not set error state. + - Add tests verifying backend error sets inline error message. + +#### Deliverables +- [ ] `AuthSheet.tsx` enforces platform button matrix (Android shows Google only). +- [ ] Web renders official GIS button and Apple popup trigger. +- [ ] Cancellation UX leaves sheet open with no errors. +- [ ] Success UX dismisses sheet cleanly. +- [ ] Real-device check executed on physical hardware: + - Physical iPhone: verify Apple native dialog and Google native dialog. + - Physical Android phone: verify Google Credential Manager dialog. + +#### Acceptance Criteria +- [ ] Pressing Apple on iOS launches native Apple dialog. +- [ ] Pressing Google on Android launches native Google Credential Manager sheet. +- [ ] Apple button is absent on Android. +- [ ] Cancelling native dialog shows no error alert or toast. +- [ ] Component tests pass in `vitest`. +- [ ] Manual smoke test passes on physical iOS and Android hardware. + +--- + +### Phase 5: Automated Tests, Contract Verification, and Workspace Verification + +**Dependencies**: Phase 1, Phase 2, Phase 3, Phase 4 + +#### Objective +Execute full automated test suites across all packages, verify contract invariants (multi-audience, nonce validation, account linking), and update operator self-hosting documentation. + +#### Files to Create / Modify +- `packages/auth/tests/config/social-auth.test.ts`: + - Full end-to-end integration tests in PGlite: + - ID token verification $\to$ session creation $\to$ `GET /api/auth/token` JWT minting $\to$ JWKS validation. + - Multi-audience validation: both `chat.ansari.app` and `APPLE_CLIENT_ID` succeed for Apple. + - Nonce validation: replayed token with missing or mismatched nonce fails with 401. + - Account linking: user registered with email/password signs in with Google/Apple ID token sharing the same email; asserts single `auth.users` row and linked `auth.account` row. + - Negative tests: tampered JWT signature, expired token, mismatched audience, missing email claim. +- `docs/self-hosting.md`: + - Document setup steps for: + - Google Cloud Console: OAuth Web Client ID, authorized JavaScript origins, Android package name. + - Apple Developer Portal: Services ID, Domains and Return URLs (for popup mode), iOS Bundle ID, Apple Key. +- Monorepo gate verification. + +#### Deliverables +- [ ] Complete automated test suite covering positive and negative cases. +- [ ] Documentation updated in `docs/self-hosting.md`. +- [ ] All workspace checks pass cleanly. + +#### Acceptance Criteria +- [ ] `pnpm --filter @ansari/auth test` passes (all PGlite tests green). +- [ ] `pnpm --filter ansari-frontend test` passes (including Android button omission test). +- [ ] `pnpm --filter ansari-frontend build:web` succeeds. +- [ ] Workspace-wide commands pass: `pnpm typecheck`, `pnpm lint`, `pnpm test`. + +--- + +## Verification Checklist + +| Layer | Check | Command | Target Outcome | +| :--- | :--- | :--- | :--- | +| **Auth Package** | PGlite social auth tests | `pnpm --filter @ansari/auth test` | Multi-audience, nonce, and account linking pass | +| **Frontend Unit** | AuthSheet & component tests | `pnpm --filter ansari-frontend test` | All tests pass, Apple hidden on Android | +| **Web Build** | Metro web export | `pnpm --filter ansari-frontend build:web` | Compiles `dist/` cleanly | +| **Real Device iOS** | Physical iPhone dev build | `npx expo run:ios --device` | Apple and Google sheets open natively | +| **Real Device Android** | Physical Android dev build | `npx expo run:android --device` | Google Credential Manager opens natively | +| **Workspace Lint** | ESLint across packages | `pnpm lint` | 0 errors | +| **Workspace Types** | TypeScript typecheck | `pnpm typecheck` | 0 errors | +| **Workspace Tests** | Full test suite | `pnpm test` | All suites green | + +--- + +## Rollback & Failure Modes + +- **Invalid / Replayed ID Token**: If signature is invalid, token is expired, or nonce does not match, returns 401 `INVALID_TOKEN`. Client displays inline error: "Unable to verify sign-in credentials. Please try again." +- **Unverified Provider Email**: Fails closed with 401 `USER_EMAIL_NOT_FOUND`. Account is not created. +- **Provider Outage / Network Error**: Client catches network failure, preserves typed fields, and displays inline error notice. +- **Missing Google Play Services (Android)**: Handled gracefully by `react-native-nitro-google-signin`, surfacing an actionable error without crashing the app. +- **OAuth Credentials Unset on Server**: If self-hoster has not provided Google/Apple credentials, `socialProviders` is inactive; email/password authentication and guest mode continue to function normally. diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md new file mode 100644 index 00000000..747a9e10 --- /dev/null +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -0,0 +1,583 @@ +# Plan: Service API keys and the WhatsApp token exchange (apps/auth) + +**Specification**: [codev/specs/329-apps-auth-service-api-keys-bet.md](../specs/329-apps-auth-service-api-keys-bet.md) + +## Executive Summary + +The plan implements the spec's Approach 1: +- `@better-auth/api-key` 1.7.7 for keys, created in-process by operator scripts, with its HTTP surface blocked by derivation and prefix, and better-auth's logger redacted; +- the WhatsApp user, account and session rows written directly with Drizzle; +- the exchange's decision (eligibility, ban, ~~phone number,~~ session) *(superseded by phase 8: no phone number)* made in one transaction holding `FOR NO KEY UPDATE` on the user row; the JWT is signed **after** that transaction commits, with the `exp` the session was checked against. + +The order follows three rules: +1. **Probe before building, with a fail-closed gate before each probe's first consumer.** If a probe's result differs from the plan, work stops at that gate and the builder raises it with the security architect: + - **before phase 4:** the key probes (`createApiKey` from a script, `shouldStore: false`, `updateApiKey`); + - **before phase 5:** the JWT probe (`signJWT` claims and `exp`), the field probe (`returned: false` / `input: false`), the session-token probe, and the real-Postgres lock and pool probes; + - **before phase 6:** the logger probe and the outage-mapping probe (phase 2). +2. **No commit publishes a surface it does not also guard.** The plugin is wired in the same commit that blocks its routes. The exchange route lands with its CORS placement, key check and timeouts in one commit. +3. **Library before service.** The identity and session core lives in `packages/auth` and is tested on pglite, then the apps/auth route wraps it. + +Eight phases, each one commit in a single PR. Phase 8 implements spec §5, owner amendment 2 of 2026-10-30 ("drop phone number entirely, whatsapp id is enough"). That amendment withdraws amendment 1, the phone-number plugin and claiming by number, which was planned here and reviewed but never built. Deploy order (amended 2026-10-28, owner decision): the PR merges to `develop` (staging) once `0001` is applied and verified on staging; at the production cutover, the full auth journal as it stands then (`0000`, `0001` and any later migration) is applied in order before the promoted code deploys, owned by `docs/cutover/better-auth-cutover.md`. + +**The green command for every phase** is the root `pnpm test && pnpm typecheck && pnpm lint` (Turborepo, all packages), from a cold cache at least once per phase (`--force`), because shared-package changes in `packages/auth` reach apps/auth and apps/api. + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "phase_1", "title": "Plugin, schema and migration 0001, blocked key surface, library probes"}, + {"id": "phase_2", "title": "Redacted Better Auth logger"}, + {"id": "phase_3", "title": "Reserved noreply domain"}, + {"id": "phase_4", "title": "Service users and key operator scripts"}, + {"id": "phase_5", "title": "WhatsApp identity and session core"}, + {"id": "phase_6", "title": "The exchange route, boot assertion and alerts"}, + {"id": "phase_7", "title": "End-to-end contract test, source scan and documentation"}, + {"id": "phase_8", "title": "Remove the phone number (owner amendment 2)"} + ] +} +``` + +## Limit sizing (owner decision 3, deferred to this plan) + +**Sized by judgement, not data (owner, 2026-10-28).** The owner reports low traffic, one WhatsApp service instance initially, and no specific numbers. The caps exist to bound a **leaked key** and a **looping client**, not to meter normal use. So the values below carry about an order of magnitude of headroom over any plausible low-traffic day. The alerts sit well **below** the caps, so a surge is seen before anything is refused. These are the **initial values: the decision, not placeholders.** Every number is a named constant in `packages/auth/src/limits.ts`. + +**Why the message rate bounds the exchange rate.** The consumer caches one token per BSUID per process for under 15 minutes. With one instance, a conversation costs about one exchange per 15 minutes, plus one after each restart. A looping or cache-broken client costs one per message. The caps are set against the second case. + +| Constant | Initial value | Reasoning | +|---|---|---| +| `WHATSAPP_KEY_INITIAL.rateLimitMax` per `rateLimitTimeWindow` | 60 per 60 s | "tens per minute": far above one instance's normal exchange rate, low enough that a leaked key or a loop is capped within a minute | +| `WHATSAPP_KEY_INITIAL.refillAmount` per `refillInterval` | 3,000 per 24 h | "low thousands": hundreds of active conversations a day at one exchange per 15 minutes, with headroom | +| `WHATSAPP_EXCHANGE_PER_USER` (per user, `consumeWindow`) | 6 per 15 min | "a handful per token lifetime": normal use is 1 (plus a restart), and a looping client for one user hits 429 at once | +| `WHATSAPP_EXCHANGE_KEY_ALERT` (per-key, per minute) | 20 per min | one third of the window cap: a surge alerts before anything is refused | +| `WHATSAPP_EXCHANGE_DAILY_ALERT` (per-key, per day) | 1,000 per day | one third of the daily budget, for the same reason | +| `EXCHANGE_UNAUTHORIZED_ALERT` (401 volume) | 20 per 5 min | a healthy consumer sees no 401s except briefly during rotation | +| `WHATSAPP_KEY_BOUNDS` (what the create and update script accepts) | window cap 1 to 100,000 per window, window 1 s to 24 h; daily budget 1 to 10,000,000 | wide on purpose: raising a value never needs the bound to change, so it needs no code review of the bound | + +**Applying and changing the key's values.** +- The key's two values live in the key's row, set by the operator through the script. The spec's no-defaults rule stands: the script requires them as explicit flags. The runbook's issuance command passes `WHATSAPP_KEY_INITIAL`'s values, so the constant is the recorded decision. +- To change them, the script has an `update-limits` subcommand that calls the plugin's server-side `auth.api.updateApiKey` with the new window cap, window and refill. It is validated against the same bounds, and probed in phase 1. Revoke-and-recreate (a rotation) is the documented fallback. The runbook carries both paths. +- Changing the per-user budget or an alert threshold is a one-line edit to `limits.ts`. + +**Where the alert counters live.** Not in `auth.rate_limit`: better-auth's pruner deletes rows older than the longest window it knows (an hour), so a daily counter there would vanish under low traffic, a check that passes by not running. +- **The per-key minute and day alerts read the key's own row**, which `verifyApiKey` returns on success: `requestCount` within the current window, and `refillAmount − remaining` since `lastRefillAt`. The plugin keeps both durably and atomically, and they reset with the window and the refill. An alert fires when the minute count reaches `WHATSAPP_EXCHANGE_KEY_ALERT` or the day count reaches `WHATSAPP_EXCHANGE_DAILY_ALERT`. It fires once per window or refill period, deduplicated in process by the window start or `lastRefillAt`; a restart can re-fire once, which errs loud. +- **The short-window alerts** (401 volume per 5 minutes, `USAGE_EXCEEDED` and `RATE_LIMITED` once per window) are in-process counters. A restart loses at most one window; the service runs one instance. +- A clock-advance test shows the daily alert fires after a day's worth of verifies spread over hours, with pruning running in between. + +**Other constants** (not traffic-dependent), all in `limits.ts`: + +| Constant | Value | Why | +|---|---|---| +| `APIKEY_RELATION_CHECK_TTL` | 5 s | bounds the pre-401 check under an unknown-key flood; also the length of the misclassification residual | +| `SESSION_MARGIN` | 5 min | the session must outlive the token's `exp` by at least this much | +| `EXCHANGE_LOCK_TIMEOUT` | 2 s | a held user row fails one request, not the pool | +| `EXCHANGE_STATEMENT_TIMEOUT` | 5 s | the same, for any slow statement in the transaction | +| `EXCHANGE_BODY_LIMIT` | 1 KB | `bsuid` ≤ 256 + ~~`phone_number` ≤ 32~~ + JSON overhead (phase 8: `phone_number` accepted in any shape, ignored; room kept for it) | + +## Phase Breakdown + +### Phase 1: Plugin, schema and migration 0001, blocked key surface, library probes + +**Dependencies**: None + +#### Objective + +Add the plugin and its table, and close its HTTP surface in the same commit. Prove every library behaviour the later phases rely on, so a wrong assumption surfaces before code is built on it. + +#### Files to Create / Modify + +- `packages/auth/package.json`, `pnpm-lock.yaml`: `@better-auth/api-key` pinned to `1.7.7` (exact). +- `packages/auth/src/schema.ts`: the `apikey` table, mirroring the plugin's model with generator column names, uuid id, `timestamptz`, and `reference_id` uuid → `auth.users` on delete cascade. ~~`users.phoneNumber` (`phone_number`, nullable text).~~ *(superseded by phase 8: no phone number)* `unique('account_provider_id_account_id_unique').on(providerId, accountId)` on `account`. The header comment records the departures. +- `packages/auth/drizzle/0001_*.sql`, `drizzle/meta/_journal.json`, `drizzle/meta/0001_snapshot.json`: generated by `pnpm --filter @ansari/auth db:generate`, SQL reviewed. **Before committing the journal entry, message `architect:main` so main numbers it.** +- `packages/auth/src/index.ts`: + - the `apiKey({ enableMetadata: true, startingCharactersConfig: { shouldStore: false }, enableSessionForAPIKeys: false, rateLimit: { enabled: true } })` plugin; + - `API_KEY_PATHS`, derived from `apiKey().endpoints` and filtered to defined paths; + - `BLOCKED_AUTH_PATHS` including them; + - ~~the `phone_number` additional user field (`input: false`, `returned: false`).~~ *(superseded by phase 8: no phone number)* +- `apps/auth/src/middleware/blocked-paths.ts`: an `/api/auth/api-key` prefix rule beside `ADMIN_PREFIX`. +- `packages/auth/tests/migration/auth-journal-parity.test.ts`: the "single fresh 0000" assertion becomes `idx` `[0, 1]`. **`AUTH_DEPLOYED_THROUGH`:** after the cutover, main sets it to `0000`. The deployed-state test then signs up against a database migrated through `0000` only, and the `phone_number` additional field makes that insert fail. (Amended 2026-10-28, owner decision; security architect, pending main's confirmation:) `AUTH_DEPLOYED_THROUGH` is production truth, and production has nothing applied until the cutover, so this PR leaves it `null` and adds no `auth-journal-applied` record; main sets it at the cutover to the last migration applied then, with one record per migration. +- `packages/auth/src/db.ts`: an explicit pool `max` (`AUTH_POOL_MAX`, 10, pg's default made visible) and `connectionTimeoutMillis` (`AUTH_POOL_CONNECT_TIMEOUT`, 5 s), so a starved pool is a loud error (500), never an unbounded wait during which `lock_timeout` and `statement_timeout` cannot fire. Both are constants in `db.ts`; they are pool settings, not rate limits. +- `packages/auth/tests/schema/auth-schema.test.ts`: seven tables; `apikey` shape; `account (provider_id, account_id)` unique; ~~`users.phone_number`~~. *(superseded by phase 8: no phone number)* +- `packages/auth/tests/config/api-key.test.ts` (new): probes 1, 2 and 4a as permanent pinning tests. +- `apps/auth/tests/blocked-paths.test.ts`: the API-key surface tests. +- `apps/auth/scripts/probes/exchange-lock-probe.ts` (new, operator-run): probe 4 against a throwaway Postgres 18 container. + +#### Deliverables + +- [ ] Plugin pinned and wired; table and migration `0001` generated and reviewed; main consulted before the journal commit. +- [ ] `API_KEY_PATHS` derived with assertions **7 endpoints, 5 routed**, and no `undefined` in `disabledPaths` or the middleware's set. +- [ ] Prefix rule; the finite variant list from the spec blocked for all 5 paths, plus `/verify` and `/delete-all-expired-api-keys` under the prefix. +- [ ] Pinning tests (pglite) for: + - **Probe 1.** `auth.api.createApiKey({ body: { userId, prefix, name, permissions, metadata, rateLimit…, remaining, refillAmount, refillInterval } })`: the key verifies with the right permission; in its row the raw key appears in no column, `start` is null, and `key === defaultKeyHasher(raw)`; `permissions` and `metadata` round-trip. + - **Probe 2.** `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })`: the claim set equals a `/api/auth/token` JWT's (keys, `alg`, `iss`, `aud`, `exp − iat`); the passed `exp` is honoured. Without `iat` the claim sets differ, which pins the reason for passing it. + - **Probe 1b.** `auth.api.updateApiKey` server-side changes `rateLimitMax`, `rateLimitTimeWindow`, `refillAmount` and `refillInterval` on an existing key, and the change takes effect at the next verify. + - **Probe 4a.** `phone_number` is absent from `get-session` and sign-in responses, and ignored when sent to sign-up. + - The probed plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval; disabled is kept; expired is deleted; a wrong permission spends no budget; `valid: false` for an unknown key. + - `enableSessionForAPIKeys: false`: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` yields nothing. +- [ ] **Session-token probe:** `generateRandomString(32, 'a-z', 'A-Z', '0-9')` from the public `better-auth/crypto` export produces tokens of the same length and alphabet as a token `internalAdapter.createSession` writes (32 characters, `[A-Za-z0-9]`). This is the generator phase 5 uses. `generateId` itself is not exported from better-auth's entry, and `@better-auth/core` is not a dependency, so no deep import is used. +- [ ] **Probe 4** (real Postgres 18, `exchange-lock-probe.ts`, run by the builder against `docker run postgres:18`, output pasted into the review). Every step under a deadline that fails red: + - under `FOR NO KEY UPDATE` on a user row, a session row inserted in the same transaction commits without waiting; + - a ban (`UPDATE users SET banned…; DELETE FROM session…`) on another connection waits for the exchange and then deletes its session; + - a session insert from another connection does not wait on the lock (`KEY SHARE` compatibility); + - `lock_timeout` turns a held `FOR UPDATE` from another connection into error `55P03`; + - **pool starvation:** with the pool's `max` deliberately set to 2, 10 concurrent exchanges (lock, decide, write the session, commit, then sign, which reads `jwks` on the pool) all finish, or fail with the pool's connect timeout as a 500. None hangs. The same run with signing moved *inside* the transaction reproduces the hang, which records why signing is outside. + +#### Acceptance Criteria + +- [ ] The green command passes. +- [ ] The migration SQL creates exactly the `apikey` table, the `account` unique constraint ~~and the `users.phone_number` column~~, and nothing else (phase 8: no phone column). +- [ ] **Gate before phase 4:** probes 1 and 1b match the plan. **Gate before phase 5:** probes 2 and 4a, the session-token probe and every step of probe 4 match the plan. A mismatch stops work at that gate and goes to the security architect. + +#### Test Plan + +Pinning tests on pglite as above. The probe 4 script is manual, against a disposable container. The blocked-path tests reuse the admin-path variant helper. + +### Phase 2: Redacted Better Auth logger + +**Dependencies**: Phase 1 (the plugin is the reason, and its catch-and-log path is the test case) + +#### Objective + +Make better-auth's logger obey the repo rule "never log a raw driver error", without silencing it. This is its own commit, because it changes every better-auth log line in apps/auth. + +#### Files to Create / Modify + +- `packages/auth/src/logger.ts` (new): `redactLogArgs`. An `Error` argument becomes exactly `{ name, code }`, where `code` comes from the error's own `code` or its `cause`'s, the same rule as `account.ts`'s `errorMeta`. Every other argument passes through unchanged. A `log(level, message, ...args)` writes through `console` at the matching level. +- `packages/auth/src/index.ts`: `logger: { log: … }` in `createAuth`. +- `packages/auth/tests/unit/logger.test.ts` (new). + +#### Deliverables + +- [ ] The redacting logger, wired into `createAuth`. +- [ ] Tests (probe 3): + - a non-error log survives with its level and message; + - a pg `DatabaseError` (built with `code: '42P01'` and a message containing an email) reduces to exactly `{ name: 'DatabaseError', code: '42P01' }`; + - near misses survive unchanged: a plain object with `name`/`code` fields, and a string argument; + - the plugin's own path: `verifyApiKey` against a pglite database with `auth.apikey` dropped logs only the redacted form, and returns `valid: false` / `INVALID_API_KEY`, which pins the outage mapping phase 6 relies on. + +#### Acceptance Criteria + +- [ ] All suites pass. No existing test that asserts better-auth log output needs editing, or each one that does is listed in the commit message. +- [ ] The criterion cannot be met by disabling the logger: the non-error survival test fails if `disabled: true`. +- [ ] **Gate before phase 6:** the logger probe and the outage mapping (`auth.apikey` dropped → `valid: false` / `INVALID_API_KEY`) match the plan. + +#### Test Plan + +Unit tests on the formatter. Integration through `verifyApiKey` with the relation dropped, capturing `console.error`. + +### Phase 3: Reserved noreply domain + +**Dependencies**: None (independent of phases 1 and 2; ordered here so phase 4's scripts can rely on it) + +#### Objective + +Reserve `@noreply.askansari.ai` on every public path, while `isSystemAddress` stays exactly as it is. + +#### Files to Create / Modify + +- `packages/auth/src/reserved.ts`: + - `NOREPLY_EMAIL_DOMAIN = '@noreply.askansari.ai'` and `isNoreplyAddress`; + - `isReservedAddress` = system **or** noreply; + - address builders `whatsappAddress(userId)` and `serviceAddress(name)`. + + `isSystemAddress` and `SYSTEM_EMAIL_DOMAIN` are unchanged. +- `packages/auth/src/reset-email.ts`: the sender skips any reserved address (`isReservedAddress`), not only system ones. +- `packages/auth/src/index.ts`: exports. The hook and `signUpGuard` already call `isReservedAddress`, so they need no logic change. +- `apps/auth/scripts/grant-admin.ts`: refuse a noreply address, as it refuses the system domain. +- Tests: `packages/auth/tests/config/sign-up.test.ts`, `packages/auth/tests/unit/reset-email.test.ts`, `apps/auth/tests/reset.test.ts`, `apps/auth/tests/grant-admin.test.ts`, and a new `packages/auth/tests/unit/reserved.test.ts`. + +#### Deliverables + +- [ ] Reserved check, reset skip, `grant-admin.ts` refusal and address builders. +- [ ] Tests: + - sign-up with a noreply address (mixed case, surrounding spaces) returns the 409 body identical to a taken address; + - near misses `someone@askansari.ai`, `x@noreply.askansari.ai.example.com` and `x@notnoreply.askansari.ai` sign up; + - a reset request for an existing noreply user returns the response an unknown address gets, with the mailer never called; + - the `user.create.before` hook refuses both domains; + - sign-in for a noreply user returns the same response as an unknown address; + - `grant-admin.ts` refuses a noreply address; + - `isSystemAddress` tests pass unedited. + +#### Acceptance Criteria + +- [ ] All suites pass, and `isSystemAddress` / `SYSTEM_EMAIL_DOMAIN` are byte-unchanged in the diff. + +#### Test Plan + +Through `auth.api` and the apps/auth HTTP harness (`startService`), as the existing sign-up and reset tests do. + +### Phase 4: Service users and key operator scripts + +**Dependencies**: Phases 1 and 3 + +#### Objective + +The operator can create a service user and issue, list, rotate and revoke its keys, in `grant-admin.ts`'s posture, with the raw key printed once and a durable record of every action. + +#### Files to Create / Modify + +- `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` and `WHATSAPP_KEY_INITIAL` (see Limit sizing). +- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions and key prefix (`whatsapp → { whatsapp: ['mint'] }, wa_`; `ansari-mcp → { mcp: ['complete'] }, amcp_`, reserved, no route yet). It also exports `SECRET_SCANNING_PATTERNS`, one regex per prefix (`wa_[A-Za-z]{64}`, `amcp_[A-Za-z]{64}`), the patterns the runbook asks the owner to register in GitHub. `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. +- `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `update-limits --key --rate-max --rate-window --daily`, `revoke --key `. Each write takes a `--confirm `. + - It refuses to run without a database URL. + - Show first; write only with a matching `--confirm` (a mismatch writes nothing, exit 2). + - Before any write it prints the database hostname and port only (`urlHost`, already in `scripts/cutover/rehearsal-anomalies.ts`, moved to a shared helper). + - It logs the key id acted on. + - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions and prefix, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. + - `revoke` sets `enabled = false` and keeps the row. `rotate` is `create-key`, plus a printed reminder to revoke the old key after the consumer switches. + - Each create, rotate, update-limits and revoke sends one Sentry event (service, key id, name, action; no key material). The script initialises Sentry and awaits `Sentry.flush` with a timeout before exit. If the flush fails or times out, the write stands, the script prints a loud banner telling the operator to record the key id and action in the runbook's key log, and exits non-zero so it is noticed. +- `apps/auth/tests/service-keys.test.ts` (new); `apps/auth/tests/helpers/` as needed. + +#### Deliverables + +- [ ] `service-users.ts` and the script, with all of the above. +- [ ] Tests: + - no database URL → refused; + - show writes nothing; a mismatched confirm writes nothing; + - the output for `postgres://user:secret@db.example:6543/auth?sslmode=require` contains `db.example:6543` and none of `user`, `secret`, `sslmode`; + - the create output contains the raw key exactly once, and the row holds only its hash (probe 1's assertions); + - list prints no key or hash; + - rotate → two live keys, both verify; + - revoke → `enabled = false`, row kept, verify refused; + - update-limits → the new values are in the row and enforced at the next verify; out-of-bounds values are refused; + - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; + - the Sentry event is captured with no key material, and the script awaits the flush before exit; a failed flush prints the banner and exits non-zero, with the write kept; + - per subcommand (`create-service`, `create-key`, `list`, `rotate`, `update-limits`, `revoke`): refuses without a database URL; refuses a mismatched `--confirm`; prints hostname and port only; logs the key id it acted on (where a key is involved); + - each `SECRET_SCANNING_PATTERNS` entry matches a key freshly created for its service, and does not match a 63-letter tail, the other service's prefix or a 32-character session token; + - output fields: `list` prints id, name, prefix, permissions, enabled, limits, created and last-request dates per key; `rotate` prints the new key once plus the old key's id; `revoke` prints the key id and `enabled = false`; none prints a key or a hash; + - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). + +#### Acceptance Criteria + +- [ ] The green command passes. A runnable harness, `apps/auth/scripts/probes/service-keys-demo.ts`, builds a pglite database from the journal, runs every subcommand in sequence and prints their outputs, so the documented output is checked by running it, not by reading it. + +#### Test Plan + +Script functions are tested directly, as `grant-admin.test.ts` tests its parser and actions. Sentry through the existing test transport. + +### Phase 5: WhatsApp identity and session core + +**Dependencies**: Phases 1 (schema, signJWT behaviour, lock probe) and 3 (address builders) + +#### Objective + +The exchange's whole decision as a library function in `packages/auth`, testable on pglite without HTTP: resolve or create the user, then, under the lock, check eligibility and ban, ~~update the phone number,~~ write the session and sign. *(superseded by phase 8: no phone number)* + +#### Files to Create / Modify + +- `packages/auth/src/whatsapp.ts` (new): + - `resolveOrCreateWhatsappUser(db, bsuid, ~~phone~~)`: a fixed-provider lookup. With no row, one transaction: generate the UUID, insert the user (`whatsapp-@noreply.askansari.ai`, `WhatsApp user`, `registered_via = 'whatsapp'`, `phone_number` when given) and the `whatsapp` account row. A unique violation on `(provider_id, account_id)` rolls back and re-reads the winner. Returns `{ userId, created }`. *(superseded by phase 8: no phone number)* + - `issueWhatsappToken(auth, db, { userId, bsuid, ~~phone~~ })`: one transaction with `SET LOCAL lock_timeout` / `statement_timeout`. *(superseded by phase 8: no phone number)* + 1. `SELECT … FOR NO KEY UPDATE` on the user; gone → `UserGone`. + 2. Eligibility: the user's sole account row is `whatsapp` **with `account_id` equal to this request's BSUID** (so a row re-pointed between resolve and lock is caught), noreply address, `registered_via = 'whatsapp'`, role not admin; else `Ineligible`. + 3. `isBanned` → delete sessions, commit, `Banned`. + 4. ~~The phone number, updated when provided and different.~~ *(superseded by phase 8: no phone number)* + 5. `iat = now`, `exp = now + JWT lifetime`; reuse a session with `expiresAt ≥ exp + SESSION_MARGIN`, else delete the user's sessions and insert one directly. Every column is named: `token` from `generateRandomString(32, 'a-z', 'A-Z', '0-9')` (`better-auth/crypto`), `expiresAt` 30 days, `createdAt` and `updatedAt` (no database default for `updatedAt`), `ipAddress` `''` and `userAgent` `''` (better-auth's own values without a request), `userId`. + 6. Commit. + 7. **After the commit**, `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })` with the same `iat` and `exp`. Signing reads `jwks` on the pool; inside the transaction it would need a second connection and could starve the pool (probe 4). The invariant holds: at signing time the session outlives the token, because `expiresAt` was checked against this `exp` before the commit. A signing failure → 500; the session stays and is reused next time. + + Typed outcomes, not thrown strings. +- `packages/auth/src/limits.ts`: `SESSION_MARGIN`, `EXCHANGE_LOCK_TIMEOUT`, `EXCHANGE_STATEMENT_TIMEOUT`, `WHATSAPP_EXCHANGE_PER_USER`. +- `packages/auth/tests/whatsapp.test.ts` (new); rerun `apps/auth/scripts/probes/exchange-lock-probe.ts` against the real functions. + +#### Deliverables + +- [ ] Both functions, with typed outcomes. +- [ ] Tests (pglite): + - new BSUID → one user (exact address), one account row, one session, ~~with `phone_number` stored when given~~; *(superseded by phase 8: no phone number)* + - repeat → same `sub` and `sid`; + - margin → replaced, old deleted; + - ~~phone: updated when different, untouched when absent, only in its column (scan every row and column);~~ *(superseded by phase 8: no phone number)* + - forced conflict on the unique constraint → one user, both callers get it; + - a ban committed between resolve and lock → `Banned`, no session; + - user deleted between resolve and lock → `UserGone`, no session; + - eligibility matrix (a `whatsapp` row on an email, service or admin user; a promoted user; an altered address; an altered `registered_via`; the `whatsapp` row's `account_id` changed between resolve and lock) → `Ineligible`, no token; + - an expired ban (`banExpires` in the past) is not refused; + - claim-set equality with `/api/auth/token`, and `{sub, sid}` equal to `definePayload`'s output for the same session; + - the session token is 32 characters from `[A-Za-z0-9]`, pinned, so a silent fallback to a weaker generator fails a test (it is a 30-day bearer credential); + - `ipAddress` and `userAgent` are `''` and `updatedAt` is set, as a better-auth-written session; + - a directly written session is accepted by the bearer plugin (`/api/auth/token` with its token) and by `/api/account/me`'s live-sid check; + - the BSUID appears only in `account.account_id`. +- [ ] A source-level test: the eligibility re-read, ban check, ~~phone update~~ and session write sit inside the `FOR NO KEY UPDATE` transaction body; `signJWT` sits **after** it, and `consumeWindow` is not in it. *(superseded by phase 8: no phone number)* +- [ ] Probe 4 rerun against these functions on real Postgres; output in the review. + +#### Acceptance Criteria + +- [ ] The green command passes; probe 4 (including pool starvation) green on real Postgres under deadlines, rerun against these functions. + +#### Test Plan + +pglite with seams (a hook between resolve and lock) for the interleavings, real Postgres for the lock itself, and source-level for transaction scope. Each states what it proves, per the spec. + +### Phase 6: The exchange route, boot assertion and alerts + +**Dependencies**: Phases 1, 2, 4 and 5 + +#### Objective + +`POST /api/whatsapp/session` on apps/auth, exactly to the spec's contract, plus the definite-answer boot assertion and the alerts. + +#### Files to Create / Modify + +- `apps/auth/src/routes/whatsapp.ts` (new): the router. + 1. Bearer → `verifyApiKey({ key, permissions: { whatsapp: ['mint'] } })`. + 2. On `valid: false`, `checkApiKeyRelation` (a query on `auth.apikey`, success cached for `APIKEY_RELATION_CHECK_TTL`) → 401 or 500. A missing or malformed bearer → 401 with no database query. + 3. `findServiceUser('whatsapp')`: not exactly one → 500 + alert; `referenceId` mismatch → 401. + 4. The route first checks `Content-Type` itself: anything but `application/json` → the exact 400. (body-parser 2.x skips an unsupported media type instead of raising, so the error handler alone would never see it, and an unparsed body is treated as 400 too.) Then route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })`, followed by a route-local error handler. It maps the parser's errors (`entity.parse.failed`, `entity.too.large`, a wrong content type) to the exact 400 `{ "message": "Invalid request" }`, so no Express error page and no body fragment is ever sent. Sanity bounds → the same 400. + 5. `resolveOrCreateWhatsappUser`. + 6. `consumeWindow` on the pool, keyed `sub:|/api/whatsapp/session` → 429 with integer `X-Retry-After`. + 7. `issueWhatsappToken` → 200, 403 or 500; a lock timeout → 500. + + Every error logs `{name, code}` only. No response contains the bearer, BSUID, phone number or body. +- `apps/auth/src/app.ts`: mount `app.use(WHATSAPP_SESSION_PATH, whatsappRouter(…))` **before** `app.use(corsFor(…))`, with a comment on why. Mounting first is not enough by itself: a request the router does not answer would fall through to `corsFor`, which answers `OPTIONS` preflights. So **the router terminates every request to its path**: `POST` is the exchange; every other method, `OPTIONS` included, gets 404 with an empty body and no `Access-Control-*` header, and never calls `next()`. +- `apps/auth/src/boot.ts` (new) and `apps/auth/src/index.ts`: `assertSchema(db)` before `listen()`. + - Query `auth.apikey`: `42P01` → refuse. + - Catalog lookup for the constraint: empty → refuse. + - Every refusal logs `{name, code}` only (the missing-constraint case included). + - Any other error → log `{name, code}` and continue to `listen()`. +- `packages/auth/src/users.ts`: the stale "boot assertion" header line now names `assertSchema`. +- `apps/auth/src/alerts.ts`: per-key volume (per minute and per day), `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts, windows and hash prefixes only, the `alerts.ts` posture. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). The per-key minute and day counts come from the key row, as described under Limit sizing. +- `packages/auth/src/limits.ts`: `APIKEY_RELATION_CHECK_TTL`, `EXCHANGE_BODY_LIMIT`, `WHATSAPP_EXCHANGE_KEY_ALERT`, `WHATSAPP_EXCHANGE_DAILY_ALERT`, `EXCHANGE_UNAUTHORIZED_ALERT`. +- `apps/auth/src/alerts.ts` (where `Sentry.init` lives): a `beforeSend` scrub for the exchange path (no body, no `Authorization`). +- Tests: `apps/auth/tests/whatsapp-session.test.ts` (new), `apps/auth/tests/boot.test.ts` (new), `apps/auth/tests/public-face.test.ts` (route inventory). + +#### Deliverables + +- [ ] Route, mounting, boot assertion, alerts and Sentry scrub. +- [ ] Tests (HTTP through `startService`): + - every key refusal → the byte-identical 401 (missing, non-Bearer, empty, session token, JWT, unknown, disabled, expired, exhausted, rate-limited, `mcp` only, no permissions, another service's `whatsapp: mint` key), and after each one the user, account and session row counts are unchanged; + - delegation inputs over HTTP: an admin's and an email user's id and email, and a `google` user's `account_id`, passed as `bsuid` → each creates a new WhatsApp user, never the seeded one; + - an expired ban is let through; + - the `rate_limit` row's key contains the user id and neither the BSUID nor the phone number; + - malformed JSON, a `text/plain` body, a missing `Content-Type` and an over-limit body → the exact 400 body, with no HTML and no body fragment; + - the exhaustive body cases from the spec (non-JSON, oversized, `bsuid` missing, non-string, empty, over 256 characters, with whitespace, with a control character; ~~`phone_number` non-string, empty, over 32 characters, non-digits~~ (phase 8: every `phone_number` shape → 200, ignored)) → 400; a sane but unusual `bsuid` is accepted; + - a missing or malformed bearer → 401 with **zero** database queries (a query-counting `Db` wrapper); + - `UserGone` (user deleted between resolve and lock) → 500 over HTTP, with no session row; + - a JWT issued before a ban fails `GET /api/account/me` after the ban; + - Sentry events from the exchange contain no phone number; + - each alert fires once per window, not per request; + - `GET`, `PUT` and `OPTIONS` on the path → 404 with no `Access-Control-*` header; + - service user missing → 500 + alert; + - body cases → 400 after a valid key; a body with no key is never parsed (spy); + - happy path, repeat, 403 banned, 429 with integer `X-Retry-After`; + - the counter survives a 403 and a 500; + - another user is unaffected; + - a lock held from another connection → 500 within the timeout; + - CORS: no `Access-Control-*` on `POST` or `OPTIONS` for each `CORS_ORIGIN`, `null`, `https://evil.example` and no `Origin`; a middleware-order test pins the mount before `corsFor`; + - not reachable under `/api/auth/*`; + - `auth.apikey` dropped under the running service → 500 after the TTL elapses; + - log capture across all cases: no raw key, hash, session token, BSUID, phone number or driver error; + - a captured Sentry event has no body or `Authorization`; + - raw response bytes contain none of the request's sensitive values; + - alerts fire at their thresholds with counts only. +- [ ] Boot tests: + - `42P01` → refuses to start with a `{name, code}` log; + - constraint missing → refuses; + - database unreachable → starts, `/health` reports down, and recovers when the database returns. +- [ ] Production-shaped run (manual, recorded in the review): apps/auth with `NODE_ENV=production` against the Postgres 18 container; stop the container under the running service; after `APIKEY_RELATION_CHECK_TTL`, a valid key and an unknown key both get 500; restart the container and the exchange recovers. + +#### Acceptance Criteria + +- [ ] All suites pass. The public-face route inventory lists the new path. `ROUTER_RULES` are unchanged (all `false`). +- [ ] The production-shaped run shows 500, never 401, after the TTL, and recovery. + +#### Test Plan + +HTTP integration against `startService` on pglite. Real Postgres for the outage and the lock timeout. Log, Sentry and response-byte capture as the spec requires. + +### Phase 7: End-to-end contract test, source scan and documentation + +**Dependencies**: Phase 6 + +#### Objective + +Prove an exchange token works in apps/api unchanged, prove apps/api has no WhatsApp code, and give the operator and the consumer everything they need. + +#### Files to Create / Modify + +- `apps/api/tests/contract/whatsapp-exchange.test.ts` (new): start apps/auth through the existing harness, seed a service user and key, exchange a BSUID, and call an apps/api route with the token. `requireUser` passes, first-use `GET /api/account/me` runs, and a `public.users` row exists under the same UUID with `registered_via = 'whatsapp'`. The phone number is in no apps/api row or response. +- `apps/api/tests/auth/resolve-user.test.ts`: one test pinning that apps/api's first-use refusal covers only the system domain. A `whatsapp-@noreply.askansari.ai` profile from `/me` is accepted and its app row created. WhatsApp users must pass it, so a future tightening of `resolve-user.ts` would otherwise break WhatsApp with no failing test. +- `apps/api/tests/source-scan/` (existing scan module): a `whatsapp` scan over `apps/api/src/**` and `apps/api/lib/**` (tests excluded) that reports its hit count (0). Its negative tests: a known-bad fixture line under the scanned glob is caught, and a near miss outside it is not. +- `docs/self-hosting.md`: the route, the scripts, `limits.ts` bounds, the migration-before-code order. +- `docs/cutover/service-keys-runbook.md` (new): + - issuance; + - the handoff (straight into the consumer's Railway variable); + - rotation overlap and the doubled ceiling; + - revocation; + - pruning (none expected); + - the limit values, their judgement-based reasoning, and how to change them (`update-limits`, or revoke-and-recreate); + - the pre-apply duplicate check for `(provider_id, account_id)`, with the ACCESS EXCLUSIVE lock note and quiet-window guidance; + - the alert meanings, and the eligibility repair procedure; + - the key log: where the operator records key id and action when a script's Sentry flush fails; + - scheduled rotation every 6 months per key, owned by the operator, with the rotate, switch, revoke steps; + - secret scanning: the owner registers each `SECRET_SCANNING_PATTERNS` entry as a GitHub organisation custom pattern (Settings → Code security → Secret scanning → Custom patterns), and the runbook says what to do when one fires (revoke at once, rotate, check the alerts for use). + + The runbook must not contain a bare `auth-journal-applied:` line outside backticks: the parity test scans `docs/**` for that record. +- `codev/resources/arch.md` (Authentication & Authorization): that apps/api's first-use refusal covers only the system domain, and why WhatsApp users depend on it; service keys, the exchange, the noreply domain, and the alert posture. +- `packages/auth/README.md`: the `apikey` table and migration `0001`. +- Coordination (outside this repo's diff): the contract summary, sent to the #327 builder through the architect, with the spec 321 amendment on their branch: header, `phone_number`, error statuses, no per-message retry on 500, re-exchange once on an apps/api 401, and the staging smoke (5 messages within 1 s) recorded as a comment on PR #327. + +#### Deliverables + +- [ ] The end-to-end test, the source scan with its negative tests, and the four documents. +- [ ] The contract summary sent. + +#### Acceptance Criteria + +- [ ] All suites pass, the root `turbo run test` included; the scan reports 0 hits plus its two negative results. +- [ ] Documentation consistency tests (`self-hosting-docs.test.ts`, `cutover-docs.test.ts`) pass or are extended for the new content. +- [ ] No new environment variable (checked by the four-way derivation); `turbo.json` is unchanged. + +#### Test Plan + +The contract test through the existing harness; scan negative tests; doc consistency tests. + +### Phase 8: Remove the phone number (owner amendment 2) + +**Dependencies**: Phase 7 (the shipped exchange, tests and docs, which this phase changes in place) + +#### Objective + +Implement spec §5, owner amendment 2: no phone number is stored anywhere. The exchange accepts `phone_number` and ignores it, so the merged #327 keeps working. Migration `0001` loses `auth.users.phone_number`, in place. Identity, eligibility and the delegation rule are unchanged, because they were always BSUID-only. + +#### Step 1: confirm `0001` is applied nowhere (T12) + +Before editing `0001`, read staging's auth database: `select to_regclass('auth.apikey')` and whether `auth.users` has a `phone_number` column, through the operator, read-only. +- Absent everywhere (expected, since the owner says `0001` has not run): `0001` changes in place. +- Present anywhere: stop. `0001` stays as it is, and a new `0002` drops the column. That decision is named here so it is not made under deploy pressure. + +The result goes in the review. + +#### Files to Create / Modify + +**Schema and migration** +- `packages/auth/src/schema.ts`: remove `phoneNumber` from `users`. +- `packages/auth/drizzle/0001_*`: regenerated in place with `drizzle-kit generate --name api_key`, after removing the old `0001` and its snapshot from the journal. The tag becomes `0001_api_key`, since the old name would mislead. The SQL is reviewed again; `AUTH_DEPLOYED_THROUGH` stays `null`. Every reference to the old tag is updated in the same commit: the journal tests, the copy and auth-journal tests, the cutover, runbook and self-hosting docs, and the README. +- `packages/auth/tests/schema/auth-schema.test.ts` and `tests/migration/auth-journal-parity.test.ts`: `auth.users` has no `phone_number`. +- `packages/auth/tests/schema/better-auth-model.test.ts` (new, the owner's compatibility ask): `getAuthTables(createAuth(…).options)` against the journal-built database. Every model and field better-auth expects exists, with a compatible type and nullability. One deviation is allowed and commented: `apikey.reference_id` is `uuid` with a foreign key, where the model says string. Negative control: a column dropped from the test database turns it red. + +**Better Auth config** +- `packages/auth/src/index.ts`: remove the `phone_number` additional field. +- `packages/auth/tests/config/api-key.test.ts` and `options.test.ts`: the probe 4a pins of `phone_number` (refused at sign-up; absent from sign-in and get-session) are removed. A test that sign-up with a `phone_number` field writes no such value replaces them, since better-auth's own unknown-field handling then applies. + +**The exchange** +- `packages/auth/src/whatsapp.ts`: `resolveOrCreateWhatsappUser` and `issueWhatsappToken` take no phone. The create path writes none, and the phone update under the lock is removed. The source-level placement assertion loses "the phone update". +- `apps/auth/src/routes/whatsapp.ts`: `exchangeBody` reads `bsuid` only. `phone_number` is accepted in any shape (no bound, no 400) and never read further. `EXCHANGE_BODY_LIMIT` keeps room for it: the field may still arrive, in any shape, until #327 stops sending it. +- `packages/auth/src/limits.ts`, `reserved.ts` and `apps/auth/src/alerts.ts`: comments that name the phone number are kept where it stays a needle, such as "never logged", and the rest removed. + +**Tests** +- `packages/auth/tests/whatsapp.test.ts` and `apps/auth/tests/whatsapp-session.test.ts`: + - the phone storage, update and "untouched when absent" tests are removed; + - the body tests turn the `phone_number` 400 cases into **200 accepted-and-ignored** cases (a number, a string, `null`, empty, 33 characters, non-digits); + - a new test sends a distinctive number, then scans every column of every table in the `auth` schema (`information_schema.columns`, cast to text) and finds it in no row; + - the needles stay: the never-log capture, the Sentry scrub test, and the raw-response-bytes assertion all still include the number. +- `apps/auth/tests/whatsapp-session-sentry.test.ts`: the number stays a needle. +- `apps/api/tests/contract/whatsapp-exchange.test.ts`: the exchange still sends a number. The app-database dump still finds no number, and the auth side's all-tables scan joins it. +- A source-level check: no file in `packages/auth/src` or `apps/auth/src` reads `phone_number`, beyond the route's documented "accepted and ignored" line. Its negative test: a planted read is caught. + +**Probes** +- `packages/auth/scripts/probes/library-probes.ts` and `exchange-lock-probe.ts`: the 4a phone probe and the phone parts of F1–F4 are removed. Their earlier outputs stay in the review as the record. +- `packages/auth/scripts/probes/phone-plugin-probe.ts`: not committed as code. Its output and the security architect's T1–T20 go in the review as the record of why amendment 1 was withdrawn. + +**Docs** +- `docs/cutover/service-keys-runbook.md`, `docs/self-hosting.md`, `codev/resources/arch.md` and `packages/auth/README.md`: the phone column and its storage rules are removed. The contract reads "`phone_number` accepted and ignored", and the never-log lists keep the number. +- The new tag `0001_api_key` everywhere the old one was named. +- Coordination: none from this builder. The security architect asks #327's author, through the community architect, to stop sending the field, and a later PR removes it from the contract. + +#### Deliverables + +- [ ] Step 1 recorded (staging read), and `0001` regenerated in place as `0001_api_key` with no `phone_number`. +- [ ] The better-auth model test, with its one documented deviation and a negative control. +- [ ] The exchange accepts and ignores `phone_number`; the all-tables scan, the needles and the source check. +- [ ] Phone tests, probes and docs removed or updated; the old tag renamed everywhere. + +#### Acceptance Criteria (spec §5) + +- [ ] `0001` creates no `phone_number`; the schema, journal-parity and model tests agree. +- [ ] Every shape of `phone_number` → 200, and afterwards the all-tables scan finds it in no row; no response, log line or Sentry event carries it. +- [ ] The source check reports no read of `phone_number` beyond the route's accept line; its planted negative goes red. +- [ ] All suites green, typecheck and lint clean; no new environment variable; the doc tests updated for the new tag. + +#### Test Plan + +pglite for the exchange and the all-tables scan; the model test against the journal-built database, with a dropped-column negative control; the existing capture and Sentry tests with the number kept as a needle; apps/api's contract test end to end; doc tests for the renamed tag. Mutation checks: storing the number again turns the scan red, and a 400 on an odd `phone_number` turns the accept tests red. + +## Criterion → owning phase → test + +Each spec success criterion has exactly one acceptance owner. An earlier probe may pre-test it, but the owner's test is the one that must stay green. + +| Spec criterion (short) | Owner | Test | +|---|---|---| +| Plugin pinned to 1.7.7, core not bumped | 1 | `packages/auth/tests/config/api-key.test.ts` (version read from the lockfile) | +| `apikey` table shape; `0001` with the constraint and `phone_number` | 1 | `tests/schema/auth-schema.test.ts`, `tests/migration/auth-journal-parity.test.ts` | +| Boot refuses on `42P01` or a missing constraint; starts when the database is unreachable | 6 | `apps/auth/tests/boot.test.ts` | +| Blocked list derived, 7 endpoints / 5 routed, finite variant list | 1 | `apps/auth/tests/blocked-paths.test.ts` | +| `enableSessionForAPIKeys` false: no session or token from a key | 1 | `tests/config/api-key.test.ts` | +| Key row: raw key absent, `start` null, hash equality | 1 | `tests/config/api-key.test.ts` (probe 1) | +| Create prints the key once; list, rotate and revoke output fields | 4 | `apps/auth/tests/service-keys.test.ts` | +| Every subcommand: URL refusal, confirm, hostname and port only, key id logged | 4 | `service-keys.test.ts` (per subcommand) | +| Create refuses a budget without refill, out-of-bounds limits, expiry | 4 | `service-keys.test.ts` | +| Service user and `service` row in one transaction; duplicate refused | 4 | `service-keys.test.ts` | +| Rotate: two live keys; revoke disables and keeps | 4 | `service-keys.test.ts` | +| Script Sentry events, no key material; flush failure banner and exit code | 4 | `service-keys.test.ts` | +| Secret-scanning patterns match real keys and reject near misses | 4 | `service-keys.test.ts` | +| Runbook covers scheduled rotation and secret scanning | 7 | review of `docs/cutover/service-keys-runbook.md` | +| Refilled exhaustion refuses and keeps, then verifies after the interval | 1 | `tests/config/api-key.test.ts` | +| `grant-admin.ts` refuses noreply | 3 | `apps/auth/tests/grant-admin.test.ts` | +| Every key refusal → identical 401, no rows | 6 | `apps/auth/tests/whatsapp-session.test.ts` | +| Missing or malformed bearer → 401 with zero database queries | 6 | `whatsapp-session.test.ts` | +| `auth.apikey` dropped / database stopped → 500 after the TTL | 6 | `whatsapp-session.test.ts` (dropped); production-shaped run in the review (stopped) | +| Service user missing → 500 + alert | 6 | `whatsapp-session.test.ts` | +| User deleted mid-exchange → 500 | 5 (library), 6 (HTTP) | `packages/auth/tests/whatsapp.test.ts`; `whatsapp-session.test.ts` owns the HTTP 500 | +| New BSUID: user, account, session, exact address, verifiable JWT | 5 | `whatsapp.test.ts` | +| Same BSUID twice → same `sub` and `sid` | 5 | `whatsapp.test.ts` | +| Session ≥ `exp` + `SESSION_MARGIN`, else replaced | 5 | `whatsapp.test.ts` | +| Claim-set equality with `/token`; `definePayload` agreement | 5 | `whatsapp.test.ts` | +| Eligibility matrix → 500 + alert, never a token | 5 (library), 6 (alert) | `whatsapp.test.ts`; `whatsapp-session.test.ts` owns the alert | +| Delegation inputs are unknown BSUIDs | 6 | `whatsapp-session.test.ts` | +| Ban → 403, sessions deleted, pre-ban JWT fails `/me`; expired ban passes | 6 | `whatsapp-session.test.ts` | +| Per-user 429 with integer `X-Retry-After`; counter survives refusals; `rate_limit` key holds the user id | 6 | `whatsapp-session.test.ts` | +| Lock timeout → 500, not a hang | 6 | `whatsapp-session.test.ts`, plus probe 4 | +| Concurrency (conflict, ban under the lock, source-level scope) | 5 | `whatsapp.test.ts`, plus probe 4 | +| Exhaustive body cases → 400; content type | 6 | `whatsapp-session.test.ts` | +| BSUID only in `account.account_id`; phone only in its column | 5 | `whatsapp.test.ts` (row scan) | +| `phone_number` refused as input, never returned | 1 | `tests/config/api-key.test.ts` (probe 4a) | +| Logs carry no secret, BSUID, phone or driver error | 6 | `whatsapp-session.test.ts` (capture across all cases) | +| Logger: redacted, not silenced; near misses survive | 2 | `packages/auth/tests/unit/logger.test.ts` | +| Sentry from the exchange: no body, `Authorization` or phone | 6 | `whatsapp-session.test.ts` | +| No response contains sensitive input | 6 | `whatsapp-session.test.ts` | +| No CORS for the four Origin cases; mounted before `corsFor` | 6 | `whatsapp-session.test.ts` | +| Alerts at thresholds, once per window, counts only | 6 | `whatsapp-session.test.ts`, plus the clock-advance daily test | +| Reserved domain: sign-up 409, near misses, reset, hook, sign-in | 3 | `tests/config/sign-up.test.ts`, `apps/auth/tests/reset.test.ts` | +| `isSystemAddress` unchanged | 3 | existing tests, unedited | +| End to end through apps/api `requireUser` | 7 | `apps/api/tests/contract/whatsapp-exchange.test.ts` | +| apps/api first-use refusal is system-only | 7 | `apps/api/tests/auth/resolve-user.test.ts` | +| No WhatsApp code in apps/api (scan, negative tests, count) | 7 | `apps/api/tests/source-scan/` | +| Staging smoke | 7 (coordination) | comment on PR #327 by its builder | +| Documentation | 7 | `self-hosting-docs.test.ts`, `cutover-docs.test.ts`, review | +| `ROUTER_RULES` unchanged; no new env var | 6, 7 | `public-face.test.ts`; four-way derivation in the review | +| Amendment 2: no `phone_number` column in `0001`; schema matches better-auth's model | 8 | `auth-schema.test.ts`, `auth-journal-parity.test.ts`, `better-auth-model.test.ts` | +| Amendment 2: `phone_number` accepted and ignored; in no row, response, log or Sentry event | 8 | `whatsapp-session.test.ts` (body cases, all-tables scan), capture and Sentry tests, `whatsapp-exchange.test.ts` | +| Amendment 2: no code reads `phone_number` | 8 | source-level check with a planted negative | +| Amendment 2: `0001` applied nowhere (T12) | 8 | staging read recorded in the review | + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| A probe contradicts the spec (e.g. `signJWT` overrides `exp`, `returned: false` leaks through an adapter path, the lock pattern waits) | Medium | High | Phase 1 runs every probe first; any difference stops the plan and goes to the security architect before phase 5 | +| Migration numbering collides with another branch | Medium | High | Message `architect:main` before committing the journal | +| Signing inside the transaction starves the shared pool | Low (designed out) | High | Sign after commit; explicit pool `max` and connect timeout; the pool-starvation probe reproduces the hang with signing inside, and passes with it outside | +| The session token generator silently weakens | Low | High | The generator comes from the public `better-auth/crypto`; length and alphabet pinned by a test | +| The lockfile has two `better-call` copies (zod peer variants) | Low | Low | Harmless here: the exchange maps every `valid: false` to 401 and classifies outages by the relation check, not by error class identity | +| The judgement-based values prove too low or too high | Medium | Low | Alerts at one third of the caps, so a surge is seen first; `update-limits` changes a key's values without code; bounds are wide, so a raise needs no bound change | +| The logger change alters an existing test's expectations | Low | Low | Its own commit; affected tests listed in the commit message | +| Real-Postgres steps are manual, so they can be skipped | Medium | High | Their output is a review deliverable for phases 1, 5 and 6; the plan's acceptance criteria name them | +| Other work on `develop` reshapes `index.ts`, `app.ts` or `limits.ts` | Medium | Low | Tests pin behaviour, not line positions | +| The plugin's 7/5 shape changes on a future bump | Low | Medium | Both counts asserted; the prefix rule covers any new routed path anyway | + +## Documentation Updates + +- `docs/self-hosting.md`, `docs/cutover/service-keys-runbook.md` (new), `codev/resources/arch.md` (Authentication & Authorization), `packages/auth/README.md`. +- `codev/resources/lessons-learned.md`, at review time: plugin behaviours (deletion on no-refill exhaustion, outage reported as `INVALID_API_KEY`, `signJWT` without `iat`), and the `FOR UPDATE` versus `KEY SHARE` hazard. +- Spec 321's amendment lands on PR #327's branch, coordinated with its builder. diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt new file mode 100644 index 00000000..09698078 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt @@ -0,0 +1,44 @@ +I'll start by reading the key files for this phase.I read the phase-2 commit (`5f1ebf3`) and every file it touched, plus the `apps/api` endpoints the new client targets, to verify the contract assumptions. I did **not** execute `pytest`/`ruff` — doing so writes into the builder's live worktree (`tests/detailed_test_results_whatsapp_service.json`, `logs/`, caches), so my findings below are from reading. Where that limits confidence I say so. + +## Verified against the actual codebase + +- `GET /api/v2/threads` really does return an array of `{thread_id, updated_at}` ordered `desc(updatedAt)`, and `createMessage` bumps `threads.updatedAt` — so `AnsariClientReal.get_last_thread_info`'s "newest-first, bumped on every message" comment is accurate, not wishful. +- `POST /api/v2/threads` returns `thread_id` and accepts optional `source` — matches `create_thread`. +- `POST /api/v2/threads/[id]` streams raw text with ZWSP keep-alives; `RAW_TEXT_HEARTBEAT_INITIAL_DELAY_MS` is **15 s**, not 60 s, so the client's 60 s httpx timeout has comfortable margin. `HEARTBEAT_CHAR = "\u200b"` stripping is correct. +- `askansari-whatsapp` passes `CLIENT_ID_PATTERN` in `@ansari/types/attribution`; the allowlist is deliberately not enforced ("sanitize, don't allowlist"), so this is fine. +- `apps/auth` has no `/api/whatsapp/session` yet — as the spec anticipated. `MOCK_AUTH_CLIENT` is the right bridge. +- Ruff would not flag the new 132-char line in `meta_api_service_real.py:104`: it's a bare URL with no whitespace past the limit, which ruff's E501 skips (same reason the pre-existing `app_logger.py` URLs pass). + +## Phase deliverables + +All five deliverables and the three acceptance criteria are substantially met. The phone number is no longer *extracted at all* (`parse_meta_payload` reads `from_user_id` and never `from`), which is stronger than filtering it downstream. `test_logs_contain_no_phone_number_bsuid_or_message_text` is a real guard, not a vacuous one — I traced every log statement on the happy path (webhook utils, conversation manager, both mocks) and confirmed each one would have to be changed to a leaking form for the assertion to matter, and that such a change would fail it. + +## Findings + +**1. `get_subject()` can raise `AttributeError`, and that returns a raw 500 to Meta.** `token_utils.get_subject` catches `(IndexError, ValueError)`. A payload segment that is valid JSON but not an object (e.g. `"a.NQ.c"` → `5`) reaches `payload.get("sub")` → `AttributeError`. In `whatsapp_conversation_manager.py:133` that call sits *outside* the try/except, so it propagates through `main_webhook` (line 372, unguarded) to `general_exception_handler`, which returns 500 **bypassing `create_response_for_meta`** — Meta retries. One-line fix: add `AttributeError, TypeError` to the `except` in `get_subject`, or move line 133 inside the existing try. + +**2. The token exchange is awaited inline, with a 15 s timeout, before Meta gets its response.** `AuthClientReal` sets `timeout=15.0`, and `authenticate_user()` is awaited before `create_response_for_meta`. The service's own docstring in `whatsapp_webhook_utils.py` states Meta wants 200 within ~5 s. Legacy registration had the same shape, so this isn't a regression — but 15 s is a number chosen in *this* phase, and spec decision 5 explicitly accepts no duplicate suppression, so a slow `apps/auth` turns into duplicate answers. Suggest dropping the auth timeout to ~5 s (it's a single DB-backed token mint), or moving auth into the background task alongside `handle_text_message`. + +**3. The typing-indicator loop is never cancelled on the auth-failure path.** `send_typing_indicator_then_start_loop` is scheduled at `main.py:360`, before the auth check at 372. On failure the background queue runs the loop (5-minute cap, 26 s interval) *and then* the "couldn't sign you in" notice — so a user who just got an error keeps seeing "typing…" for five minutes. Same applies to the message-too-old and unsupported-type returns. Cheapest fix: schedule the typing loop only after authentication succeeds. + +**4. The apps/api half of the "no phone number" criterion isn't asserted.** The plan says "No outgoing call to `apps/auth` **or `apps/api`** carries a phone number (asserted in tests)." `test_phone_visible_user_is_identified_and_replied_to_by_bsuid_only` asserts the auth request and the Meta recipient, but the webhook flow runs against `AnsariClientMock`, so no apps/api wire bytes are inspected. The risk is genuinely near-zero (no phone number is ever parsed), but the criterion is literal. You already have `_use_transport` — pointing the *real* ansari client at a `MockTransport` inside the webhook test would close it cheaply. + +**5. Mid-stream apps/api errors reach the user verbatim.** `apps/api` appends `\n\nError: ` to the 200 stream on facilitator error or empty answer. `process_message` returns that as a normal answer, so the user gets the raw error text and the WhatsApp side logs nothing. The spec's test scenario asks for "a failure notice, error logged." Detecting a trailing `\n\nError: ` sentinel is brittle; worth at least recording as a known gap for Phase 4. + +**6. `from_user_id` is now a hard requirement, and the outgoing BSUID send path is untested.** Any payload without `from_user_id` now produces the "Meta sent your message in an unexpected format" reply. `from_user_id` presence depends on the webhook subscription's Graph API version, and `META_API_VERSION` defaults to `v22.0`; your own comment in `meta_api_service_real.py:99-104` notes Meta marks BSUID *sending* as "coming soon" in one place and July 2026 in another. This is correct per baked decision 2, but it's a deploy-blocking unknown — the plan asked for findings to be recorded rather than scope-widened, and this is the one worth writing down before the staging smoke test. + +**7. Minor.** `test_token_exchange_failure_notifies_user_and_is_reported` asserts the notice but nothing about reporting — rename or assert. `AuthClientReal.close()` exists but is never called (same as `AnsariClientReal` — pre-existing). `README.md:108-115` still documents the six deleted legacy endpoints; Phase 4 owns this per the iteration-1 rebuttal, so I'm only flagging it so it isn't lost. + +--- +VERDICT: COMMENT +SUMMARY: BSUID-only identity and the auth/api client split are correctly implemented and verified against the real apps/api contract; a few cheap robustness fixes and one untested-criterion gap are worth closing before Phase 3. +CONFIDENCE: MEDIUM +--- +KEY_ISSUES: +- `get_subject()` can raise an uncaught `AttributeError` outside `authenticate_user()`'s try block, returning a raw 500 to Meta instead of an OK (one-line fix). +- Token exchange awaited inline with a 15 s timeout delays Meta's response past the documented ~5 s window; lower the timeout or move auth into the background task. +- Typing-indicator loop is scheduled before the auth check and never cancelled on failure — a user who was told sign-in failed sees "typing…" for 5 minutes. +- Plan acceptance criterion "no outgoing call to apps/api carries a phone number (asserted in tests)" is not asserted; the webhook test uses the mock ansari client. +- Mid-stream `apps/api` errors arrive as HTTP 200 with inline `Error:` text and are relayed to the user unlogged, short of the spec's "failure notice, error logged". +- Hard dependency on `from_user_id` plus an untested `recipient`-based Meta send path (`META_API_VERSION` defaults to v22.0) should be recorded as a deploy finding before the staging smoke test. +- Not executed: `pytest` and `ruff` were not run, to avoid writing artifacts into the live worktree; CI green is unverified by me. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md new file mode 100644 index 00000000..c52a7b78 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md @@ -0,0 +1,13 @@ +# Rebuttal: auth_token_exchange, iteration 1 + +Review: `321-auth_token_exchange-iter1-claude.txt` (COMMENT). Findings 1 to 4 are accepted and fixed; 5 and 6 are accepted and recorded in the plan for Phase 4. + +1. **`get_subject()` could raise.** Fixed: it now also catches `AttributeError` and `TypeError`. A test with a payload that is valid JSON but not an object fails without the fix (`'int' object has no attribute 'get'`) and passes with it. +2. **Token exchange timeout.** Fixed: lowered from 15 s to 5 s, matching the window Meta expects. Moving authentication into the background task was not done: an auth failure would then no longer be reported in the webhook response, which the existing error handling relies on. +3. **Typing indicator never cancelled on failure.** Fixed: it is now scheduled only for text messages the user is signed in for, right before `handle_text_message`, the one place that cancels it. This also covers the too-old and unsupported-type returns. +4. **apps/api half of the "no phone number" criterion.** Fixed: a new test runs the full webhook flow against the real client over a mock transport, and asserts that none of the three requests (list threads, create thread, send message) contains the phone number or the BSUID. It fails when a phone number is deliberately added to a request body. +5. **Inline `Error:` text relayed to the user.** Accepted, not fixed here: detecting the trailing sentinel is brittle, and the proper fix touches answer handling. Added to Phase 4 in the plan. +6. **`from_user_id` requirement and untested BSUID send.** Accepted, recorded as a deploy blocker in Phase 4 of the plan, to be checked before the staging smoke test. +7. **Minor.** The `README.md` still documents the removed endpoints, which Phase 4 already owns; noted there. Not changed: the unused `close()` methods are pre-existing, and the failure-notice test keeps its name, because the "reported" half is the Meta error code returned to the caller. + +Verification: ruff clean, 24 tests pass, with both new red-path checks shown to fail first. Not proven: nothing has run against a live `apps/auth` or `apps/api`, since the `/whatsapp` endpoint does not exist yet. diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt new file mode 100644 index 00000000..93c8110c --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt @@ -0,0 +1,47 @@ +I'll review the Phase 4 (`environments_and_docs`) implementation. Let me start by reading the key files.I've read the Phase 4 files, run the suite, and checked lint/format. Here's my assessment. + +## What's verified working + +- `uv run pytest tests -q` → **61 passed**; `ruff check .` → clean; `ruff format --check .` → 35 files formatted. (Run from `apps/whatsapp`.) +- **`WA_MAINTENANCE_MODE`** (`config.py:89`, `main.py:337`) is this service's own setting, checked before auth and before any `apps/api` call. `test_environment_behaviour.py:79-108` covers notice-sent / no-chat-call, off-path, and that an exported `MAINTENANCE_MODE` (apps/api's flag) does not leak into it. Good, non-vacuous tests: the chat spy would catch a regression where the check moved after the chat call. +- **`!d ` filter** (`main.py:354`) gated on `DEPLOYMENT_TYPE == "staging"`, with all three cases tested (staging ignores, staging without prefix processes, local with prefix processes). +- **Railway JSON logging**: single-line JSON on stdout, level mapping, no `user_id` field, `request_id` present. `test_railway_logging.py:61-67` properly asserts stderr is empty and that an embedded newline stays inside the JSON — that's the right test for the failure mode. +- **Sentry coverage** for the Phase 3 finding is in: `_capture()` is called for `thread_info_failure`, `thread_deletion_failure`, `thread_creation_failure`, `auth_token_failure`, `message_processing_failure` (`whatsapp_conversation_manager.py:110-115` and callers), and the context carries only `request_id`. +- **Deploy items**: Dockerfile is uv `0.9.13` with `uv sync --frozen` on both syncs and repo-root-relative paths; `/sentry-debug` registered only outside production (`main.py:116`); `clean_logs.sh` gone; `PORT` honoured (`main.py:430`); `railway.toml` present and honest about not being read by Railway; CI job `whatsapp` added. + +## Findings + +**1. The README understates what was verified against live Meta, contradicting the plan's own record.** `README.md:130` says *"That `from_user_id` is present in every webhook, and that replying to a BSUID is accepted by Meta. Neither has been tried against live Meta."* The plan's Phase 4 section records the opposite for half of it: a real send to a BSUID returned 200 with the BSUID echoed as `user_id`, plus delivery/read webhooks, and Meta's own sample webhooks reached the service through zrok carrying `from_user_id`. `README.md:131` likewise says *"nothing here has run against a live `apps/auth` or `apps/api`"* while the plan records a real `apps/api` run that created a thread and answered. This is the docs phase; the next person reading the README will either redo work or distrust the record. Rewrite those two bullets to say precisely what was and wasn't done (BSUID send: verified once via direct HTTP, not through `MetaApiServiceReal`; a real phone's webhook: still untested; `apps/auth` `/whatsapp` route: still assumed). + +**2. `tests/README.md:23`** has the same overreach: *"Nothing has been run against a live `apps/auth`, `apps/api` or Meta."* True of the test suite, false of the project. Scope the sentence to the tests. + +**3. The new Sentry captures have no test.** No test file references `capture_exception`, `_capture`, or Sentry at all. The Phase 4 plan lists adding Sentry to the thread-list/create/delete failures as this phase's work, so the one piece of new behaviour added here is the one piece with no coverage. A `monkeypatch.setattr(sentry_sdk, "capture_exception", ...)` on the existing `test_thread_lifecycle.py` failure case would cost three lines and would catch someone deleting the `_capture` call. Reading the code, I'm confident the calls are correct — my point is only that nothing would notice if they vanished. + +**4. The `httpx.TimeoutException` branch in `handle_text_message` is unreachable.** `AnsariClientReal.process_message` catches `httpx.TimeoutException` at line 240 and re-raises it as `MessageProcessingError`; `AnsariClientMock` never raises it either (its only raises are `ThreadDeletionError`/`MessageProcessingError`). So `whatsapp_conversation_manager.py:284-291` — the `backend_timeout` Sentry tag and the "The request timed out" user message — can't fire. Practical effect is cosmetic (a timeout tells the user "An error occurred" and is tagged `message_processing_failure`), but the plan's Phase 4 outcome note claims "timeout … failures are captured in Sentry" as a distinct case, and it isn't. Either let the real client re-raise timeouts unwrapped, or drop the branch and the claim. + +**5. `test_json_logs_are_off_in_local_development_and_in_ci` (`test_railway_logging.py:74-77`) is vacuous for half its name.** `use_json_logs` is `DEPLOYMENT_TYPE not in [local, development] and GITHUB_ACTIONS != "true"`, and conftest pins `DEPLOYMENT_TYPE=local`. The assertion passes identically whether or not the `GITHUB_ACTIONS` clause exists — delete that clause and the test still goes green. If the CI guard matters, test it by building the expression with a staging deployment type and `GITHUB_ACTIONS=true`. + +**6. Python version drift between CI, dev and the image.** There is no `.python-version` in `apps/whatsapp` and `requires-python = ">=3.10"`, so CI's `uv sync` installs the newest compatible CPython while the Dockerfile runs `python:3.13` and this worktree's venv is 3.10.10 (I confirmed all three). CI can go green on a version that never ships. A `.python-version` pinned to 3.13 (and `setup-uv` reading it) closes that. + +**7. `.env.example` still documents a file that wasn't imported.** Lines 97-102 define `META_WABA_ID` "for `check_subscriptions.py` only" — that script doesn't exist in `apps/whatsapp`, and `META_WABA_ID` isn't a field in `config.py` (so `extra="ignore"` silently drops it). Delete it or say it's vestigial. Minor companion: `ORIGINS` on line 32 still carries an `ngrok` host from the legacy setup. + +**8. Ordering nit, no fix required:** the maintenance check (`main.py:337`) precedes the `!d ` filter (`main.py:354`), so a staging instance in maintenance mode replies to a local developer's `!d ` message with the maintenance notice — the exact double-answer the prefix exists to prevent. Harmless and legacy-faithful; mentioning so it's a choice rather than an accident. + +I verified the things I'd normally flag from training data rather than asserting them: `POST /api/v2/users/login` referenced in `.env.example:180` **does** exist in `apps/api` (legacy pass-through to `apps/auth`, see `apps/api/tests/contract/harness.ts:67`), so that line is correct and I'm not flagging it. + +Nothing here blocks the phase — the deliverables are present, tested and green. Items 1 and 2 should land before the PR description is written, since the PR will cite the same facts. + +--- +VERDICT: COMMENT +SUMMARY: Phase 4 deliverables are complete and green (61 tests, clean lint/format); the issues are documentation accuracy in the very phase that owns docs, plus a few test-depth and version-drift nits. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- `README.md:130-131` claims the live-Meta BSUID send and the real `apps/api` run were never done, contradicting the plan's own Phase 4 record — fix before the PR description reuses it +- `tests/README.md:23` overstates the same ("nothing has been run against a live … Meta"); scope it to the test suite +- The Sentry captures added in this phase (`_capture` for thread info/create/delete, auth, timeout) have zero test coverage — nothing would notice their removal +- `handle_text_message`'s `httpx.TimeoutException` branch is unreachable: `AnsariClientReal` wraps timeouts into `MessageProcessingError` and the mock never raises one, so the `backend_timeout` tag and timeout message never fire +- `test_json_logs_are_off_in_local_development_and_in_ci` passes with the `GITHUB_ACTIONS` clause deleted — vacuous for the CI half of its name +- No `.python-version`: CI resolves the newest CPython, the Dockerfile runs 3.13, this worktree runs 3.10.10 +- `.env.example:97-102` documents `META_WABA_ID` for `check_subscriptions.py`, a file that was not imported and a setting `config.py` does not define \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt new file mode 100644 index 00000000..febcf7f3 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt @@ -0,0 +1,76 @@ +I'll start by reading the key files to evaluate this phase.I read every file in scope from disk and ran the phase's acceptance checks. Note: I ran `ruff` and `pytest` as verification; these wrote only gitignored artifacts (`logs/`, `.pytest_cache/`, `tests/detailed_test_results_whatsapp_service.json`). I did not modify, create, or delete any tracked file. + +## Acceptance criteria — verified passing + +| Check | Result | +|---|---| +| `uv lock --check` | ✅ Resolved 53 packages, lock consistent with `pyproject.toml` | +| `ruff check .` | ✅ All checks passed | +| `ruff format --check .` | ✅ 28 files already formatted | +| `pytest tests/ -q` | ✅ 5 passed in 8.62s | +| `.env.example` has no secrets | ✅ placeholders only (`your_app_secret_here`, `<>`) | +| Excluded artifacts | ✅ no `zrok.exe`, no `logs/`, no `tmp/`, no personal scripts beyond `clean_logs.sh` — app-level `.gitignore` covers all of them | +| Existing monorepo unaffected | ✅ `pnpm-workspace.yaml` globs `apps/*`; pnpm skips a directory with no `package.json`, so install/build/test are untouched | + +The `conftest.py` addition is a good call — `os.environ.setdefault` makes the suite runnable with no `.env` while letting real values win, and it documents that intent in one line. + +## Must fix + +**1. CI wiring is a named phase-1 plan item that was silently skipped.** + +The plan's phase-1 *Files to Create / Modify* says: *"Monorepo wiring only where needed (workspace and CI entries), following how other apps are registered."* `.github/workflows/ci.yml` is untouched and has no `whatsapp` job — the file list confirms it. No later phase claims CI either: phase 4 owns maintenance mode, the `!d ` filter, README and deploy notes, not workflows. + +Consequence: everything I verified above passes on *this* machine and nowhere else. `ruff check`, `ruff format --check` and `pytest` for `apps/whatsapp` will never run on any PR, and phases 2–4 add the substantive logic (auth exchange, thread lifecycle, maintenance mode) whose tests would be equally invisible. This is precisely the pattern `codev/resources/lessons-critical.md` warns about — *"Prefer failures that are loud over checks that are quiet"* — and the cost of deferring compounds with each phase. + +Related and part of the same fix: **`ruff` is not a declared dependency.** It appears in `[tool.ruff]` config but is absent from `dependencies` and from `[project.optional-dependencies].dev` (which holds only `fastapi[standard]`). I had to run it from a globally installed copy. A CI step would have no pinned version. Add it to the dev extras (or a `[dependency-groups]` block) and relock. + +Suggested job, mirroring the pinning discipline of the existing ones (`astral-sh/setup-uv` pinned to a full SHA with a trailing version comment): + +```yaml + whatsapp: + name: whatsapp (lint, format, test) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: apps/whatsapp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@ # vX.Y.Z + - run: uv sync --frozen + - run: uv run ruff check . + - run: uv run ruff format --check . + # conftest.py supplies mock-mode defaults, so no env/secrets are needed. + - run: uv run pytest tests/ -v +``` + +Please prove it both directions before calling it done — confirm the job fails on a deliberately broken test/format, and goes green again when restored. A job that passes by not running is worse than no job. + +## Comments (non-blocking) + +**2. `README.md` and `tests/README.md` carry dangling legacy references.** `README.md` points at `setup.sh` / `setup.bat`, `git clone … && cd ansari-whatsapp`, `CLAUDE.md`, `docs/hld/architecture.md`, `docs/lld/implementation_guide.md`, `docs/lld/aws/README.md`, `docs/lld/github_actions/README.md`, `docs/whatsapp_migration_plan/migration_plan.md` — none of which were imported. Its env table also lists `HOST`, `PORT` and `DEV_MODE` (absent from `config.py`) and gives `META_API_VERSION` default `v21.0` where `config.py:51` says `v22.0`. + +Phase 4 owns the README rewrite, so this is scheduled rather than missing. But `tests/README.md` is *not* named in any phase's file list and has the same problem (lines 7, 72–74, 88, 112, 147, 155–156, 175 reference `../ansari-backend`). Add it to phase 4's doc scope so the fix lands everywhere, not partially — a half-fixed doc set that disagrees with itself is worse than the original. + +**3. `app_logger.py:40` — `os.makedirs("logs", exist_ok=True)` at import, CWD-relative, feeding a sink that is commented out.** The only consumer (the file handler at lines 173–188) is disabled, so this creates an empty directory for nothing. Root `.gitignore` has no `logs/` entry, so running anything from the repo root leaves an untracked `logs/` at the monorepo root. Cheap to delete, or move it inside the commented block so the two travel together. + +**4. Phone numbers and message bodies reach the logs and Sentry.** `whatsapp_webhook_utils.py` lines 175, 182 and 205 raise with the full webhook body interpolated, and line 236 logs `f"Received a supported whatsapp message from {user_whatsapp_number}: {incoming_msg_body}"`. `main.py:323–332` routes the resulting exception to `sentry_sdk.capture_exception`. Correctly out of scope for an as-is import, and phase 2's deliverable already says *"Logs and errors contain no phone numbers"* — I flag it only so that work remembers the `_redact_for_logging` helper already sitting at line 141 is the tool for exactly these four sites. + +**5. The spec's signature-rejection scenario has no owner.** *"Invalid or missing Meta signature: rejected"* is in the spec's Test Scenarios, but no test covers it and no later phase's file list claims it. `verify_meta_signature` is correct (it uses `hmac.compare_digest`), and the helper `_post_signed_webhook` you added at line 354 makes the negative case a three-line test — worth claiming now rather than letting it fall between phases. + +**6. No `codev/state/spir-321_thread.md`.** CLAUDE.md asks each builder to keep one. It is also how the next reviewer distinguishes "deferred on purpose" from "forgotten" — had a thread entry said CI was deliberately pushed to a follow-up, item 1 would read differently. + +**7. Trivia.** `main.py:156` annotates `verification_webhook -> str | None` but returns `HTMLResponse` (harmless — FastAPI skips response validation for `Response` returns). And confirm `clean_logs.sh` is genuinely wanted, since the plan excluded "personal scripts." + +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Import is clean and all stated acceptance checks pass locally, but the phase's named CI-wiring plan item was skipped, so the suite runs nowhere but the builder's machine. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- No CI job for `apps/whatsapp`; `.github/workflows/ci.yml` untouched though phase 1 explicitly lists "CI entries", and no later phase claims it — ruff/pytest never run on any PR, and phases 2–4 inherit the blind spot +- `ruff` is configured in `pyproject.toml` but not declared as a dependency, so there is no pinned version for CI to use +- `README.md` and `tests/README.md` reference ~10 files that do not exist in the monorepo; `tests/README.md` is unowned by any phase (README rewrite is scheduled for phase 4 — add tests/README.md to it) +- Spec test scenario "invalid or missing Meta signature: rejected" has no test and no owning phase +- Non-blocking: `app_logger.py:40` creates a CWD-relative `logs/` for a disabled sink; phone numbers/message bodies logged and sent to Sentry (phase 2 scope); no builder thread log \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md new file mode 100644 index 00000000..e3cc786b --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md @@ -0,0 +1,33 @@ +# Rebuttal: import_service, iteration 1 + +Review: `321-import_service-iter1-claude.txt` (REQUEST_CHANGES). All must-fix points are accepted and fixed in commit `7b04c55`. + +## Must fix + +**1. No CI job for `apps/whatsapp`. Accepted, fixed.** +Added a `whatsapp` job to `.github/workflows/ci.yml` (ruff check, ruff format check, pytest, working directory `apps/whatsapp`). `astral-sh/setup-uv` is pinned to a full commit SHA with a version comment (`07a047e6…`, v10.3.2, resolved through the GitHub API), matching the other jobs. No secrets are used because `conftest.py` supplies mock-mode defaults. +The review asked to prove the check both ways. Locally, using the exact CI steps (`uv sync --frozen --extra dev`, with no environment variables set): +- Green: ruff check, ruff format check and 7 tests pass. +- Red: a deliberately wrong assertion makes pytest fail (exit 1), and deliberately bad formatting makes `ruff format --check` fail (exit 1). Both were restored afterwards and the tree was clean. +Not proven: the job has not run on a GitHub Linux runner, because it can only run when the PR is opened. The first PR run is its first real test. + +**2. `ruff` is not a declared dependency. Accepted, fixed.** +Added `ruff>=0.8.0` to the `dev` extra in `pyproject.toml` and relocked (`ruff==0.17.2`). CI installs it with `--extra dev` and uses the locked version. + +## Comments + +**3. Dangling references in `README.md` and `tests/README.md`. Accepted, scheduled.** +Phase 4 already owns the README rewrite. `tests/README.md` was missing from it, so it is now named in Phase 4's file list in the plan. + +**4. `logs/` created at import in `app_logger.py`. Not changed.** +Deferred as the review suggests, to avoid changing imported code in an as-is phase. + +**5. Phone numbers and message bodies in logs and Sentry. Not changed here.** +Already a Phase 2 deliverable ("Logs and errors contain no phone numbers"). The existing redaction helper noted by the review will be used then. + +**6. Missing test for signature rejection. Accepted, fixed.** +Added `test_webhook_rejects_missing_signature` and `test_webhook_rejects_wrong_signature` (both expect 403). Shown to fail when the assertion is wrong. + +## Environment note + +On this Windows machine porch's default whole-monorepo test check cannot pass, so the local-only `.codev/config.json` narrows it to `apps/whatsapp`. CI on Linux still runs the full suites for the other apps. diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt new file mode 100644 index 00000000..9c075113 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt @@ -0,0 +1,39 @@ +I'll review the implementation by reading the actual files.I read every changed source file, the CI workflow, the lockfile, the configs, and the git history for this branch. I did not modify anything in the tree and did not run `uv sync`/`pytest` (both write into the worktree the builder is live in). + +## Iteration 1 blocker — verified fixed + +The one must-fix from iteration 1 was that the suite ran nowhere but the builder's machine. That is genuinely resolved, and I checked each part rather than taking the rebuttal's word: + +- `.github/workflows/ci.yml:183-205` adds a `whatsapp` job with `defaults.run.working-directory: apps/whatsapp`, running `uv sync --frozen --extra dev`, `ruff check .`, `ruff format --check .`, `pytest tests/ -v`. `astral-sh/setup-uv` is pinned to a full SHA with a version comment, matching the repo's stated convention in the header comment at line 13. +- `ruff>=0.8.0` is in `pyproject.toml`'s `dev` extra (line 48), and `uv.lock` carries it both as a `package.metadata` requirement (line 51, `marker = "extra == 'dev'"`) and as a resolved package at `0.17.2` (line 1025). So `uv sync --frozen --extra dev` will not fail the "lockfile not up to date" check, and `uv run ruff` will resolve. +- The new signature tests (`test_whatsapp_service.py:372-388`) are real assertions (`assert response.status_code == 403`), not the `assert True` pattern the imported suite uses. Both paths through `verify_meta_signature` are covered: the missing/malformed-prefix branch (`whatsapp_webhook_utils.py:49`) and the `compare_digest` mismatch branch (line 68). +- No secrets: `conftest.py` supplies mock-mode defaults, so the job needs no repository secrets, consistent with the rest of the workflow. + +Phase 1's other acceptance criteria check out: `.env.example` contains only placeholders (the one real-looking number, `871020755148175`, is a public Meta app id in a URL); `git ls-files apps/whatsapp` is 35 files with no `logs/`, no `tmp/`, no `zrok.exe`; and nothing touches `turbo.json`, `pnpm-workspace.yaml`, or root `package.json`, so the existing graph is unaffected (`apps/whatsapp` has no `package.json`, so pnpm's `apps/*` glob skips it). + +## Comments (none blocking) + +**1. The Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile.** `Dockerfile:9` copies `ghcr.io/astral-sh/uv:0.5.11`, but `uv.lock:1-2` is `version = 1 / revision = 3` — a lockfile format written by a far newer uv. The `revision` key did not exist in 0.5.x. I could not prove the failure by reading alone (it depends on whether uv 0.5.11's deserializer rejects unknown keys), and nothing in this PR builds the image, so the question is open rather than settled. It's a one-line fix to remove the doubt: bump the tag to a uv that understands revision 3. Separately, `Dockerfile:44`'s second `uv sync` has no `--frozen`, so a deploy can silently resolve away from the lock that CI tested. Both are deploy-path concerns that Phase 4 owns — but Phase 1 lists the Dockerfile in its file set, so it's worth either fixing now or writing down as a named Phase 4 item. + +**2. `conftest.py`'s comment overstates what `setdefault` preserves.** "real values still win" holds for exported environment variables, but not for a developer's `.env` — pydantic-settings ranks environment variables *above* dotenv values, and `os.environ.setdefault` writes environment variables. So a developer who sets `MOCK_ANSARI_CLIENT=False` in `.env` to exercise a real backend silently gets the mock instead, which contradicts both `README.md:152` and the module docstring at `test_whatsapp_service.py:15-18`. Either reword the comment or gate the defaults on `.env` being absent. + +**3. `test_webhook_message_basic` effectively asserts only "HTTP 200".** At lines 302-318 both the `success and "processed successfully"` branch and the `else` branch call `assert True`. It has more teeth than it looks — Starlette runs `BackgroundTasks` before `TestClient` returns, so an exception in `handle_text_message` would surface — but it will not catch a response that comes back `success: False` with a 200. Since `ALWAYS_RETURN_OK_TO_META` is `False` under test, asserting `response.json()["success"] is True` costs one line and makes the happy path actually tested. This is imported legacy test style, so I'd leave it to whenever that suite is rewritten rather than ask for it now. + +**4. `/sentry-debug` (`main.py:111-113`) is unauthenticated and present in every environment.** Deliberate legacy behaviour for verifying Sentry wiring, and harmless beyond a 500, but worth a note before this reaches production in Phase 4. + +**5. `clean_logs.sh` looks like the kind of personal script Phase 1 said to exclude** ("without its logs, `tmp/` files, personal scripts..."). Minor — flagging only because it's the one file in the import that the plan's exclusion list arguably covers. + +**6. The `whatsapp` check is not a required check on `develop`.** The workflow header notes branch protection is an admin-coordinated change (lines 113-120). The job will run on PRs, but nothing blocks a merge on it failing until someone makes it required. Worth surfacing to the human rather than fixing in code. + +I did not re-raise the `logs/` creation at import (`app_logger.py:40`) or the phone numbers in logs — both are correctly deferred, and `logs/` is covered by `apps/whatsapp/.gitignore:8`. The dangling `README.md` references are confirmed scheduled: commit `7b04c55` adds `tests/README.md` to Phase 4's file list in the plan. + +--- +VERDICT: APPROVE +SUMMARY: The iteration-1 CI blocker is genuinely fixed and verified end to end (job wired, ruff declared and locked, signature tests assert properly); remaining findings are deploy-path and test-style notes that belong to Phase 4 or are legacy-as-imported. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- None blocking. Non-blocking: Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile and its second `uv sync` omits `--frozen` — fix now or name it explicitly as a Phase 4 item. +- Non-blocking: `conftest.py`'s "real values still win" comment is wrong for `.env` values (env vars outrank dotenv in pydantic-settings), so a dev's `MOCK_ANSARI_CLIENT=False` is silently ignored. +- Non-blocking: `test_webhook_message_basic` asserts only HTTP 200; both branches end in `assert True`, so a `success: False` 200 would pass. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md new file mode 100644 index 00000000..754b391e --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md @@ -0,0 +1,46 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Import is clean and all stated acceptance checks pass locally, but the phase's named CI-wiring plan item was skipped, so the suite runs nowhere but the builder's machine. + +### Builder Response to Iteration 1 +# Rebuttal: import_service, iteration 1 + +Review: `321-import_service-iter1-claude.txt` (REQUEST_CHANGES). All must-fix points are accepted and fixed in commit `7b04c55`. + +## Must fix + +**1. No CI job for `apps/whatsapp`. Accepted, fixed.** +Added a `whatsapp` job to `.github/workflows/ci.yml` (ruff check, ruff format check, pytest, working directory `apps/whatsapp`). `astral-sh/setup-uv` is pinned to a full commit SHA with a version comment (`07a047e6…`, v10.3.2, resolved through the GitHub API), matching the other jobs. No secrets are used because `conftest.py` supplies mock-mode defaults. +The review asked to prove the check both ways. Locally, using the exact CI steps (`uv sync --frozen --extra dev`, with no environment variables set): +- Green: ruff check, ruff format check and 7 tests pass. +- Red: a deliberately wrong assertion makes pytest fail (exit 1), and deliberately bad formatting makes `ruff format --check` fail (exit 1). Both were restored afterwards and the tree was clean. +Not proven: the job has not run on a GitHub Linux runner, because it can only run when the PR is opened. The first PR run is its first real test. + +**2. `ruff` is not a declared dependency. Accepted, fixed.** +Added `ruff>=0.8.0` to the `dev` extra in `pyproject.toml` and relocked (`ruff==0.17.2`). CI installs it with `--extra dev` and uses the locked version. + +## Comments + +**3. Dangling references in `README.md` and `tests/README.md`. Accepted, scheduled.** +Phase 4 already owns the README rewrite. `tests/README.md` was missing from it, so it is now named in Phase 4's file list in the plan. + +**4. `logs/` created at import in `app_logger.py`. Not changed.** +Deferred as the review suggests, to avoid changing imported code in an as-is phase. + +**5. Phone numbers and message bodies in logs and Sentry. Not changed here.** +Already a Phase 2 deliverable ("Logs and errors contain no phone numbers"). The existing redaction helper noted by the review will be used then. + +**6. Missing test for signature rejection. Accepted, fixed.** +Added `test_webhook_rejects_missing_signature` and `test_webhook_rejects_wrong_signature` (both expect 403). Shown to fail when the assertion is wrong. + +## Environment note + +On this Windows machine porch's default whole-monorepo test check cannot pass, so the local-only `.codev/config.json` narrows it to `apps/whatsapp`. CI on Linux still runs the full suites for the other apps. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt new file mode 100644 index 00000000..fa8d048b --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt @@ -0,0 +1,66 @@ +I'll review this PR by reading the diff and the actual files on disk.A `.venv` exists in the worktree — let me check gitignore coverage, and read the spec/plan/review.I've reviewed the PR by reading the files on disk, cross-checking the Python client's assumptions against the actual `apps/api` routes, and verifying the git/branch state. I did not run `pytest`/`ruff` or mutate anything in the tree. + +## What's solid + +- **Branch hygiene**: 0 behind / 46 ahead of `origin/develop`. `.venv` is correctly gitignored (43 tracked files under `apps/whatsapp`, none from the venv). Commits are consistently `[Spec 321][Phase: x]`, atomic, well-described. +- **Contract verification is real, not assumed.** I independently confirmed every cross-service assumption the Python client makes: + - `apps/api/src/app/api/v2/threads/route.ts` — `createThreadSchema` has `source: z.string().optional()`, so `"whatsapp"` is accepted. + - `packages/types/src/attribution.ts` — `askansari-whatsapp` matches `CLIENT_ID_PATTERN` and isn't allowlist-gated. + - `apps/api/lib/db/threads.ts:65` — `orderBy(desc(threads.updatedAt))`, so the client's `threads[0]`-is-newest assumption holds. + - `.../threads/[id]/chat/route.ts` — emits exactly `{type:'text',content}`, `{type:'error',message}`, `{type:'done'}`, matching the parser in `ansari_client_real.py:195-215`. +- **Signature verification** uses `hmac.compare_digest` and rejects missing/malformed headers with 403. +- **Tests** sidestep `pytest-asyncio` via `asyncio.run` (no undeclared dep), drive the real app through `TestClient` with genuine HMAC signatures, and the review documents negative controls per test. +- **The review document is unusually honest** — it leaves the 4000-char criterion as `[ ]`, and names the unbuilt image, the missing `apps/auth` route, and the race it chose not to fix. + +## Issues + +**1. (High) Nothing stops `MOCK_AUTH_CLIENT=True` + `MOCK_AUTH_TOKEN` outside `local`.** + +`auth_client_mock.py:17-18` returns the *same* JWT for every BSUID when `MOCK_AUTH_TOKEN` is set. All WhatsApp users then collapse into one `apps/api` account and one thread — every user reads and continues every other user's conversation. + +This isn't hypothetical: the PR's stated plan is to deploy with the sign-in mocked until `apps/auth` has the route, so `MOCK_AUTH_CLIENT=True` *is* the intended deployed config, and `MOCK_AUTH_TOKEN` is exactly what makes a real `apps/api` accept it. The builder clearly knows the hazard — `conftest.py:19` says "A real token in a developer's .env would make every BSUID the same user" — but that defence lives only in the test fixture, never in `config.py`. A `model_validator` on `WhatsAppSettings` raising when `DEPLOYMENT_TYPE != "local"` and any `MOCK_*` is truthy closes it in ~6 lines, and matches your own hot lesson: *prefer failures that are loud over checks that are quiet*. + +**2. (Medium) `_resolve_thread` deletes a thread it never confirmed is a WhatsApp thread.** + +`get_last_thread_info` (`ansari_client_real.py:150`) takes `threads[0]` with no `source` filter, and `_resolve_thread` deletes it on expiry. `create_thread` tags `source: "whatsapp"`, and `GET /api/v2/threads` already returns `source`. Safe today because BSUID accounts hold only whatsapp threads — but the moment the real `/whatsapp` route maps a BSUID onto an account that also has web threads, a WhatsApp message 3 hours later silently deletes the user's web thread *and its messages*. The follow-up list already describes the sweep as "keep each user's newest `whatsapp` thread," so filtering is the intended semantics anyway. + +**3. (Medium) `e.response.text` is logged raw, against a documented guarantee.** + +`meta_api_service_real.py:66` and `:143` log Meta's raw error body. `README.md:100` states unconditionally: "Logs deliberately contain no phone numbers, BSUIDs or message text." Meta's send-failure bodies can echo the recipient. Either reduce to `{name, code}` (your hot lesson on raw error objects) or qualify the README claim — right now the doc asserts something the code doesn't enforce. + +**4. (Low) Dead debug block.** `ansari_client_real.py:221-237` — a commented-out "TEMPORARY: Save response to file" block. Straight "no debug code" PR-readiness item; not mentioned in the review doc. + +**5. (Low) Nits.** `pytest` is in `[project].dependencies` rather than the `dev` extra (ships into the image); `requires-python = ">=3.10"` / ruff `target-version = "py310"` while `.python-version` and the Dockerfile pin 3.13 (the review calls this drift "addressed" — narrowed, not closed); `main.py:251` `await request.json()` sits outside the try/except so a signature-valid non-JSON body 500s through the global handler; `verification_webhook` is annotated `-> str | None` but returns `HTMLResponse`. + +**On the untested splitter:** I read all four paths. The length bound *is* sound — `split_by_fixed_chunks` is the terminal fallback on every branch — and ordering is preserved. So this gap is lower-risk than the review implies, though the follow-up test is still worth writing. + +**Not verified by me:** I didn't run `pytest`/`ruff`, so "70 tests pass, ruff clean" is unconfirmed; the `whatsapp` CI job also hasn't run on a runner yet, so nothing independent backs it. I count 57 `def test_` functions — 70 is plausible with the parametrized cases. The Dockerfile remains the deploy artifact and has never been built; worth one `docker build && docker run` before the Railway service is created, since `CMD ["python", "src/..."]` with `PYTHONPATH=/app` depends on `uv sync` having installed the src-layout project into the venv. + +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Well-executed port with honest docs and verified cross-service contracts, but the mocked sign-in can silently merge all WhatsApp users into one account if deployed as planned, and thread deletion isn't scoped by source. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- No config guard prevents `MOCK_AUTH_CLIENT=True` + `MOCK_AUTH_TOKEN` outside `local`; since deploying with a mocked sign-in is the stated plan, this collapses every WhatsApp user into one account and thread (cross-user conversation exposure). Add a `model_validator` in `WhatsAppSettings`. +- `get_last_thread_info` doesn't filter by `source`, yet `_resolve_thread` deletes what it finds — latent data loss of a user's web threads once BSUID→account linking exists. One-line filter; `GET /api/v2/threads` already returns `source`. +- `meta_api_service_real.py:66,143` log Meta's raw `e.response.text`, contradicting README:100's unconditional "no phone numbers, BSUIDs or message text in logs." +- Dead commented-out debug block at `ansari_client_real.py:221-237`. +- Dockerfile (the deploy artifact) has never been built or run; `whatsapp` CI job has never run on a GitHub runner. + +PR_SUMMARY: | + ## Summary + Moves the legacy Python WhatsApp service into the monorepo as `apps/whatsapp`. Users are identified solely by their business-scoped user ID (BSUID) — never a phone number — exchanged for a JWT via `apps/auth`, which is then used to call `apps/api`'s `/chat` event stream. Each user keeps exactly one thread, replaced after 3 hours of silence. No `apps/api` or `apps/auth` code changes. + + ## Key Changes + - `apps/whatsapp`: BSUID identity, `apps/auth` token exchange behind one client, `apps/api` `/chat` SSE aggregation, one-thread-per-user lifecycle, `WA_MAINTENANCE_MODE`, staging `!d ` filter, Railway-shaped single-line JSON logs carrying only `request_id`, Sentry capture for thread and sign-in failures, `PORT` support. + - Deploy scaffolding: `Dockerfile` (repo-root build context), `railway.toml` (git record only), `.python-version` pinned to 3.13. + - `.github/workflows/ci.yml`: a `whatsapp` job running lint, format check and tests, with actions pinned to commit SHAs. + - `codev/`: spec, plan, per-phase reviews, review document, and cold-tier arch/lessons updates. + + ## Test Plan + - `cd apps/whatsapp && uv sync --frozen --extra dev && uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -v` + - Confirm the `whatsapp` CI job goes green on a GitHub runner (it has not yet run). + - Build and run the Docker image — never exercised, and it is the deploy artifact. + - Run the staging smoke test in `apps/whatsapp/README.md` once a shared staging/test number exists. + - Before any non-local deploy: verify that setting `MOCK_AUTH_CLIENT=True` with a `MOCK_AUTH_TOKEN` cannot route two different BSUIDs to the same thread. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md new file mode 100644 index 00000000..5bcd3763 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md @@ -0,0 +1,33 @@ +# Rebuttal: review phase (PR #327), iteration 1 + +Review: `321-review-iter1-claude.txt` (REQUEST_CHANGES). The four code findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +## Must fix + +**1. Nothing stopped `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`. Accepted, fixed.** +`WhatsAppSettings` now refuses to load when `MOCK_AUTH_TOKEN` is set and `DEPLOYMENT_TYPE` is not `local`, so the service will not start in staging or production with it. Tests: local allows it, staging and production refuse it, and an empty value is fine anywhere. They fail (2 tests) when the guard is disabled. +One consequence the review's premise misses: a mocked sign-in without `MOCK_AUTH_TOKEN` hands out fake tokens that a real `apps/api` rejects, so a mocked sign-in cannot serve real users anywhere. A deployment against a real `apps/api` needs the real `apps/auth` `/whatsapp` route. The README, `.env.example` and the PR description now say so. + +**2. `_resolve_thread` could delete a thread that is not a WhatsApp thread. Accepted, fixed.** +`get_last_thread_info` now considers only threads whose `source` is `whatsapp` (the list endpoint returns `source`). Test: a newer `web` thread is ignored, and a user whose only thread is `web` counts as having none. It fails when the filter is removed. The existing real-client fixtures now include `source`, as the real endpoint does. + +**3. Meta's raw error body was logged, against the README's "no phone numbers or BSUIDs in logs". Accepted, fixed.** +Both send paths now log only the status code. Test: a failing send (the mock error body echoes a BSUID) must log the status but neither the BSUID nor the body, for the message and typing-indicator calls. It fails when the body logging is put back. + +**4. Dead commented-out debug block. Accepted, removed** from `ansari_client_real.py`. + +## Also fixed (nits) + +- `verification_webhook` was annotated `-> str | None` but returns `HTMLResponse`; corrected. + +## Not changed, with reasons + +- **`pytest` in the runtime dependencies, and `requires-python`/ruff `py310` against a 3.13 image.** Both are imported legacy packaging. Moving `pytest` to the `dev` extra changes the lockfile and the Docker install; narrowing the Python range changes supported versions. Left for a follow-up and listed in the review document; the suite passes on 3.13. +- **`await request.json()` outside the try block (a signature-valid non-JSON body returns 500).** Legacy behaviour; Meta sends JSON and the signature is checked first. Listed as a follow-up. +- **Docker image never built; CI job never run on a runner.** Unchanged and stated in the PR. Neither can be done from this machine (no Docker daemon); the first PR run is the CI job's first real test. + +## Review's own caveat on counts + +The review counted 57 `def test_` functions against "70 tests"; parametrized cases account for the difference. After these changes the suite is 77 tests. + +Verification: ruff clean, format clean, 77 passed. diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt new file mode 100644 index 00000000..ef921730 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt @@ -0,0 +1,67 @@ +I'll start by reading the diff file and then examining the actual source files.The diff file is empty (0 bytes), so I'll read the actual files from disk as my review instructions require.Fixes #1 confirmed in config.py. Now checking the client implementations for fixes #2, #3, #4.All four iteration-1 fixes verified in code. Now checking cross-service contracts and looking for new issues.The CI job has no `env:` block, yet `WhatsAppSettings` has required fields and `whatsapp_webhook_utils.py` calls `get_settings()` at **module import time**. Let me check how tests bootstrap env.The review document is unusually thorough and honest. Now let me verify the new tests actually exist and are non-vacuous.I have verified all four iteration-1 fixes in code and confirmed each has a non-vacuous test. Here is my assessment. + +## Verification of iteration-1 fixes + +| # | Finding | Status | +|---|---|---| +| 1 | `MOCK_AUTH_TOKEN` outside `local` | **Fixed** — `config.py:107-114` model validator; test at `test_bsuid_auth_flow.py:135` parametrized over local/staging/production, plus an empty-value test. Asserts the specific `ValueError` match, so it can't pass vacuously. | +| 2 | `_resolve_thread` deleting non-WhatsApp threads | **Fixed** — `ansari_client_real.py:149` filters `t.get("source") == "whatsapp"`; `test_thread_lifecycle.py:167` proves a *newer* `web` thread is skipped in favour of an older `whatsapp` one, and that a web-only user reads as having no thread. | +| 3 | Meta error body in logs | **Fixed** — `meta_api_service_real.py:65,141` log only `status_code`; `test_meta_sender_logging.py` asserts `"400" in log_text` **and** BSUID/body absent, so it fails both if the body returns and if nothing is logged at all. | +| 4 | Dead commented debug block | **Fixed** — removed. | + +Branch is 0 commits behind `develop`, 49 ahead, commits follow `[Spec 321][Phase: X]`, review document is present and unusually honest (it lists the 4000-char splitter as explicitly untested rather than claiming coverage). + +## New findings (none blocking) + +**1. The raw-error-body fix was applied to the Meta sender but not to the apps/api client.** `ansari_client_real.py:190-192`: + +```python +error_detail = await response.aread() +logger.error(f"Error from backend API: {error_detail}") +raise MessageProcessingError(f"Backend returned HTTP {response.status_code}: {error_detail}") +``` + +That exception reaches `_capture(e, "message_processing_failure")` in `whatsapp_conversation_manager.py:286`, so the body goes to **Sentry** as well as the logs. Today it leaks nothing — apps/api's error bodies are controlled (`createErrorResponse` with zod issue messages, `'Thread not found'`), and never contain a BSUID. But it's the same pattern you just removed from the Meta sender, and it contradicts apps/api's own stated convention ("No user content in logs or Sentry"). Per your own hot lesson about fixing a defect everywhere rather than where you happen to be editing, this is the one remaining instance. + +**2. The staging `!d ` filter crashes on unsupported media types.** `main.py:354`: + +```python +if get_settings().DEPLOYMENT_TYPE == "staging" and incoming_msg_body.get("body", "").startswith("!d "): +``` + +For unsupported types, `parse_meta_payload` (line 222-223) sets `incoming_msg_type = "errors"` and `incoming_msg_body = incoming_msg["errors"]` — which, per your own code comment on line 220-221 and Meta's schema, is a **list**, not a dict. `.get` on a list raises `AttributeError`. This filter sits at line 354, *before* the `incoming_msg_type != "text"` guard at line 379, so in staging a video note / giphy / poll produces an unhandled `AttributeError` → the generic handler returns 500 → Meta retries for up to 24 hours. Production is unaffected (the `== "staging"` check short-circuits first). I confirmed by reading that no test exercises the `errors` path — `grep` for `errors|unsupported` across `tests/` returns only unrelated matches. Fix is `isinstance(incoming_msg_body, dict) and incoming_msg_body.get(...)`. + +**3. One unredacted payload interpolation.** `whatsapp_webhook_utils.py:188` is the only error path that formats payload data without `_redact_for_logging`. It contains only the business's own `display_phone_number`, not a user's, so nothing leaks — but it's inconsistent with lines 176, 183, 206 and 225, which all redact. + +**4. Nit.** `main.py:364` returns `"Message too old, notified user"` and the docstring (line 222) says "Returns 200 after notifying user", but no `background_tasks.add_task` sends anything. The string only reaches Meta, so this is cosmetic. + +## On deferred items + +`pytest` in runtime deps, `requires-python >= 3.10` vs the 3.13 image, and `await request.json()` outside the try block are all correctly deferred and listed in the review document's follow-ups — I'm not re-raising them. The CI job genuinely bootstraps without an `env:` block because `tests/conftest.py` sets the required settings via `os.environ.setdefault` before any module-level `get_settings()` runs; I traced that and it holds. + +--- +VERDICT: COMMENT +SUMMARY: All four iteration-1 findings are genuinely fixed with non-vacuous tests; two small one-liners remain — an incomplete propagation of the error-body fix to the apps/api client, and a staging-only crash when the `!d` filter meets a list-shaped `errors` payload. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `ansari_client_real.py:190-192` logs and Sentry-reports the raw apps/api error body — the same pattern removed from the Meta sender in this iteration. No leak today (apps/api bodies are controlled), but inconsistent; log only the status code. +- `main.py:354` calls `.get()` on `incoming_msg_body`, which is a **list** for Meta's unsupported-type payloads (`incoming_msg["errors"]`). Staging-only `AttributeError` → 500 → Meta retry loop. No test covers the `errors` path. Guard with `isinstance(..., dict)`. +- `whatsapp_webhook_utils.py:188` is the only payload interpolation that skips `_redact_for_logging` (business metadata only, so no user data leaks). +- Nit: `main.py:364` claims "notified user" but schedules no notification task. + +PR_SUMMARY: | + ## Summary + Ports the standalone Python WhatsApp service into the monorepo as `apps/whatsapp`. Users are identified solely by their Meta business-scoped user ID (BSUID) — never a phone number — which the service exchanges with `apps/auth` for a short-lived JWT, then uses to drive `apps/api`'s `/chat` SSE stream. Each user keeps exactly one thread, replaced after 3 hours of silence. No `apps/api` or `apps/auth` code changed. + + ## Key Changes + - `apps/whatsapp`: BSUID identity, `apps/auth` token exchange, `apps/api` `/chat` event-stream client, one-thread-per-user lifecycle scoped to `source: 'whatsapp'`, `WA_MAINTENANCE_MODE`, the staging `!d ` filter, Railway-style single-line JSON logs carrying only `request_id`, Sentry capture for thread and sign-in failures. + - Privacy by construction: webhook payloads are structurally redacted before logging (`_redact_for_logging`), Meta error bodies are never logged, and a settings-level guard refuses `MOCK_AUTH_TOKEN` unless `DEPLOYMENT_TYPE=local` so a shared token can never merge users into one account. + - `apps/whatsapp/Dockerfile` (repo-root build context, uv pinned to the lockfile's revision), `railway.toml` as a reviewable record, `.python-version` pinned to 3.13. + - `.github/workflows/ci.yml`: a `whatsapp` job running ruff lint, format check and pytest. + + ## Test Plan + - `cd apps/whatsapp && uv sync --frozen --extra dev && uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -v` — 77 tests, no external services required (`tests/conftest.py` supplies mock-mode env). + - Confirm the first CI run of the `whatsapp` job goes green on a GitHub runner, then ask a repo admin to make it a required check on `develop`. + - Before any non-local deploy: implement the `apps/auth` `POST /api/whatsapp/session` route (header `X-Whatsapp-Api-Key`, body `{bsuid}`, response `{token}`) and switch off the mocked sign-in — a mocked sign-in cannot serve real users outside `local`. + - Still unverified and tracked as follow-ups: a real phone's webhook reaching the new service (blocked on a Meta test number), the Docker image build, and the 4000-character answer splitter. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md new file mode 100644 index 00000000..5756a354 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md @@ -0,0 +1,46 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Well-executed port with honest docs and verified cross-service contracts, but the mocked sign-in can silently merge all WhatsApp users into one account if deployed as planned, and thread deletion isn't scoped by source. + +### Builder Response to Iteration 1 +# Rebuttal: review phase (PR #327), iteration 1 + +Review: `321-review-iter1-claude.txt` (REQUEST_CHANGES). The four code findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +## Must fix + +**1. Nothing stopped `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`. Accepted, fixed.** +`WhatsAppSettings` now refuses to load when `MOCK_AUTH_TOKEN` is set and `DEPLOYMENT_TYPE` is not `local`, so the service will not start in staging or production with it. Tests: local allows it, staging and production refuse it, and an empty value is fine anywhere. They fail (2 tests) when the guard is disabled. +One consequence the review's premise misses: a mocked sign-in without `MOCK_AUTH_TOKEN` hands out fake tokens that a real `apps/api` rejects, so a mocked sign-in cannot serve real users anywhere. A deployment against a real `apps/api` needs the real `apps/auth` `/whatsapp` route. The README, `.env.example` and the PR description now say so. + +**2. `_resolve_thread` could delete a thread that is not a WhatsApp thread. Accepted, fixed.** +`get_last_thread_info` now considers only threads whose `source` is `whatsapp` (the list endpoint returns `source`). Test: a newer `web` thread is ignored, and a user whose only thread is `web` counts as having none. It fails when the filter is removed. The existing real-client fixtures now include `source`, as the real endpoint does. + +**3. Meta's raw error body was logged, against the README's "no phone numbers or BSUIDs in logs". Accepted, fixed.** +Both send paths now log only the status code. Test: a failing send (the mock error body echoes a BSUID) must log the status but neither the BSUID nor the body, for the message and typing-indicator calls. It fails when the body logging is put back. + +**4. Dead commented-out debug block. Accepted, removed** from `ansari_client_real.py`. + +## Also fixed (nits) + +- `verification_webhook` was annotated `-> str | None` but returns `HTMLResponse`; corrected. + +## Not changed, with reasons + +- **`pytest` in the runtime dependencies, and `requires-python`/ruff `py310` against a 3.13 image.** Both are imported legacy packaging. Moving `pytest` to the `dev` extra changes the lockfile and the Docker install; narrowing the Python range changes supported versions. Left for a follow-up and listed in the review document; the suite passes on 3.13. +- **`await request.json()` outside the try block (a signature-valid non-JSON body returns 500).** Legacy behaviour; Meta sends JSON and the signature is checked first. Listed as a follow-up. +- **Docker image never built; CI job never run on a runner.** Unchanged and stated in the PR. Neither can be done from this machine (no Docker daemon); the first PR run is the CI job's first real test. + +## Review's own caveat on counts + +The review counted 57 `def test_` functions against "70 tests"; parametrized cases account for the difference. After these changes the suite is 77 tests. + +Verification: ruff clean, format clean, 77 passed. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md new file mode 100644 index 00000000..32c326dc --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md @@ -0,0 +1,10 @@ +# Rebuttal: review phase (PR #327), iteration 2 + +Review: `321-review-iter2-claude.txt` (COMMENT). All four iteration-1 fixes were confirmed. The reviewer's four new findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +1. **The apps/api error body was logged and sent to Sentry. Accepted, fixed.** `process_message` now logs and raises with only the HTTP status code. Test: a failing `/chat` (the mock body echoes user text) must raise an error that names the status but not the body, and log neither. It fails when the body is put back into the raised error. +2. **The staging `!d ` filter crashed on unsupported message types. Accepted, fixed, and a good catch.** Meta sends an unsupported type as an `errors` list, so `.get` on it raised in staging only, giving a 500 and a Meta retry loop. The filter now looks inside the body only when it is a dict. Test: a staging webhook with an `errors` list gets `UNSUPPORTED_MESSAGE_TYPE` and no chat call. It fails when the guard is removed. +3. **One unredacted payload interpolation. Accepted, fixed.** The missing-`phone_number_id` message now goes through `_redact_for_logging` like the others. No test: it only formats business metadata and nothing reaches it in normal flows; the change is one line. +4. **`"Message too old, notified user"` while nothing notifies the user. Accepted, fixed.** The response text and the docstring now say the message is ignored. + +Verification: ruff clean, format clean, 79 passed. diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt new file mode 100644 index 00000000..b65e2a02 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt @@ -0,0 +1,32 @@ +I'll start by reading the key files for this phase.I read the phase-3 implementation (`_resolve_thread`, `user_locks.py`, the client `delete_thread` additions, and `test_thread_lifecycle.py`), and verified the `apps/api` contract it depends on by reading the actual route and DB helpers. + +## What I verified against apps/api (not from memory) + +- `GET /api/v2/threads` (`apps/api/src/app/api/v2/threads/route.ts:20-28`) returns a bare array, newest-first (`findThreadsByUser` → `.orderBy(desc(threads.updatedAt))`, `lib/db/threads.ts:65`), with `thread_id` and `updated_at` serialized via `toISOString()` → `"…T10:00:00.000Z"`. The client's `threads[0]` + `updated_at` mapping is correct, and the `.replace("Z", "+00:00")` + `fromisoformat` parse is valid on Python 3.10 (3 fractional digits). +- `updated_at` really is bumped on every message (`createMessage`, `lib/db/threads.ts:141-147`), and deliberately *not* on tool-call orphans — so "time since last message" is a faithful expiry signal. +- `DELETE /api/v2/threads/{id}` is scoped by `and(eq(threads.id,id), eq(threads.userId,userId))` (`:121-127`), returns 200 `{message:…}` and 404 when nothing matched; messages are cascade-deleted by the FK (`db/schema/messages.ts`). So treating 404 as success is right, and the spec's "the old thread **and its messages** are deleted" holds without any apps/api change. The plan asked for this to be recorded — it should go in the review doc. + +## Phase deliverables + +All three acceptance criteria are met, with tests that are better than average: `test_message_within_expiry_…` (just inside), `test_message_after_expiry_…` (just outside, asserts the old thread *and* its messages are gone), `test_another_users_thread_is_never_touched`, `test_failed_deletion_does_not_create_a_second_thread` (correctly refuses to create a second thread when the delete fails), and — the part I want to call out — `test_without_the_lock_two_close_messages_would_create_two_threads`, a negative control proving the concurrency test isn't vacuous. That is exactly the discipline this repo's lessons ask for. The in-process-only scope of the lock is documented in both `UserLocks` and `_resolve_thread`. + +## Non-blocking findings + +1. **Only thread *resolution* is serialized, not processing.** The lock is released before `process_message`, so two messages arriving a second apart both resolve the same thread and then stream concurrently against it. The thread invariant survives (that's what this phase promised), but the two turns interleave their user/assistant rows in one thread's history — which matters given the facilitator rebuilds Gemini history from those rows. Holding the lock across `handle_text_message`'s processing is a small change and only ever delays the *same* user. If you'd rather not (a 60s stream would block that user's next message), the plan's criterion says to *document the behaviour* — the docstring currently documents only the creation race. +2. **The production timestamp format isn't exercised by the lifecycle tests.** `AnsariClientMock` stores `datetime.now(timezone.utc).isoformat()` → `+00:00`, while apps/api emits `…Z`. The `Z`-handling line is the one the expiry decision depends on in production and it is only covered indirectly (`test_bsuid_auth_flow.py:147` asserts the client passes the string through, not that `_resolve_thread` parses it). A single test — stub `get_last_thread_info` to return a 4-hour-old `…Z` string, assert delete-then-create — would close that. A real-client `MockTransport` run asserting the 4-call sequence (GET → DELETE → POST create → POST message) would cover the delete wiring end to end too. +3. **`result.get("thread_id")` can return `None` silently.** If a client ever returns a dict without the key, `_resolve_thread` returns `None` and `handle_text_message` gives up without telling the user. Both current clients guarantee the key (the real one raises `ThreadCreationError` on `KeyError`), so this is robustness only — treating a missing id as a creation failure would keep the "the user was already told" contract honest. +4. **Thread info/create/delete failures log but don't reach Sentry**, unlike `process_message` failures which `capture_exception`. An apps/api thread-endpoint outage would be invisible in Sentry. Small, and a natural fit for phase 4's logging item. +5. **Deploy constraint worth writing down in phase 4:** the one-thread invariant depends on the service running as a *single* replica. That's a Railway dashboard setting, not something in the repo — the deployment notes should state it explicitly, since a second replica silently breaks the guarantee this phase just built. + +--- +VERDICT: APPROVE +SUMMARY: One-thread-per-user with expiry is correctly implemented against the real apps/api contract and well tested, including a negative control proving the per-user lock matters; remaining items are non-blocking. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- Lock covers thread resolution only; two close messages still stream concurrently into the same thread — either extend the lock over `process_message` or document the decision as the plan's criterion asks. +- Expiry parsing of apps/api's real `…Z` timestamp is untested (the mock emits `+00:00`); one test on `_resolve_thread` would cover the line production depends on. +- `_resolve_thread` can return `None` from a create response missing `thread_id`, leaving the user un-notified (robustness nit; unreachable with current clients). +- Thread info/create/delete failures are logged but not captured in Sentry, unlike message-processing failures. +- Single-replica assumption behind the invariant should be recorded in the phase-4 deployment notes (Railway replica count is dashboard config). \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml new file mode 100644 index 00000000..44e46773 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -0,0 +1,56 @@ +id: '321' +title: rfc-whatsapp-support-in-the-mo +protocol: spir +phase: verify +plan_phases: + - id: import_service + title: Import the Python service as is + status: complete + - id: auth_token_exchange + title: Replace legacy user handling with the apps/auth token exchange + status: complete + - id: thread_lifecycle + title: One thread per user with expiry + status: complete + - id: environments_and_docs + title: Maintenance mode, staging filter, local testing and docs + status: complete +current_plan_phase: null +gates: + spec-approval: + status: approved + approved_at: '2026-10-25T08:07:52.213Z' + plan-approval: + status: approved + approved_at: '2026-10-25T08:16:16.104Z' + pr: + status: approved + requested_at: '2026-10-27T07:05:18.575Z' + approved_at: '2026-10-27T07:46:54.822Z' + verify-approval: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-25T06:51:50.273Z' +updated_at: '2026-10-27T07:47:04.774Z' +context_refreshes: + - boundary: enter:plan + at: '2026-10-25T08:08:13.901Z' + acknowledged_at: '2026-10-25T08:08:22.211Z' + - boundary: enter:implement + at: '2026-10-25T08:16:19.523Z' + acknowledged_at: '2026-10-25T08:16:31.277Z' + - boundary: plan-phase:auth_token_exchange + at: '2026-10-25T10:21:36.093Z' + acknowledged_at: '2026-10-25T14:49:39.861Z' + - boundary: plan-phase:thread_lifecycle + at: '2026-10-25T14:58:53.028Z' + acknowledged_at: '2026-10-25T14:59:15.993Z' + - boundary: plan-phase:environments_and_docs + at: '2026-10-25T16:14:17.696Z' + acknowledged_at: '2026-10-27T05:52:35.516Z' + - boundary: enter:review + at: '2026-10-27T06:09:40.030Z' + acknowledged_at: '2026-10-27T06:22:14.280Z' +pr_ready_for_human: false diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt new file mode 100644 index 00000000..5dc63be8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt @@ -0,0 +1,8 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Implementation is sound, but two explicit Phase 1 test deliverables remain incomplete. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Probe 1b does not prove all four limit fields change: `rateLimitTimeWindow` remains `60_000`, `refillInterval` is not asserted, and refill behavior is not exercised after update. +- The required absolute-form request target is tested for only two of the five routed API-key endpoints, not each endpoint as specified. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md new file mode 100644 index 00000000..8965b34d --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md @@ -0,0 +1,6 @@ +# Phase 1, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Probe 1b did not prove every field changed: accepted. The update now changes all four (`rateLimitMax` 1→5, `rateLimitTimeWindow` 60 s→30 s, `refillAmount` →2, `refillInterval` →1 s, plus `remaining` →1), asserts each column, and exercises each at the next verify: the window cap, the exhausted budget, then a refill past the new interval (remaining refilled to 2, one spent). +2. The absolute-form target covered 2 of 5 endpoints: accepted. The target list is now every `API_KEY_PATHS` entry plus a mixed-case variant. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt new file mode 100644 index 00000000..45ad6599 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 1 meets its scoped deliverables, including the corrected limit-update and complete blocked-path probes. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md new file mode 100644 index 00000000..0df7be29 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md @@ -0,0 +1,20 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Implementation is sound, but two explicit Phase 1 test deliverables remain incomplete. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 1, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Probe 1b did not prove every field changed: accepted. The update now changes all four (`rateLimitMax` 1→5, `rateLimitTimeWindow` 60 s→30 s, `refillAmount` →2, `refillInterval` →1 s, plus `remaining` →1), asserts each column, and exercises each at the next verify: the window cap, the exhausted budget, then a refill past the new interval (remaining refilled to 2, one spent). +2. The absolute-form target covered 2 of 5 endpoints: accepted. The target list is now every `API_KEY_PATHS` entry plus a mixed-case variant. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt new file mode 100644 index 00000000..a2148307 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 2 correctly wires and tests a non-silencing, error-redacting Better Auth logger. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt new file mode 100644 index 00000000..11929869 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Reserved-domain coverage is solid, but padded noreply sign-ups violate the explicit 409 contract. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `signUpGuard` validates before normalization, so `" service-whatsapp@noreply.askansari.ai "` returns 400. The spec and phase deliverable require the same 409 response as a taken address. The test currently codifies the deviation instead of the required behavior. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md new file mode 100644 index 00000000..fc3093b5 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md @@ -0,0 +1,9 @@ +# Phase 3, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES (one issue); gemini COMMENT (skipped, `agy` not installed). + +**Padded noreply sign-up returns 400, not 409: disagree with changing the code; the spec text is corrected instead.** + +- `signUpGuard` validates the email before the reserved/taken check. That is the legacy register order (validation → reserved or taken → strength), pinned by spec 300 and by apps/api's frozen `/v2/users` contract suite. Trimming before validation would change sign-up for every address, which is outside this spec and touches a frozen contract. +- The property the criterion protects is "no oracle". It holds: a padded noreply address, a padded taken address and a padded fresh address all get the identical 400 `INVALID_EMAIL`, and unpadded noreply and taken addresses get the identical 409. The test asserts both. +- The spec's "(any case, surrounding spaces) → 409" was my drafting error: the existing guard never gave 409 for a padded address, the system domain included. The spec criterion is amended to state the real behaviour (same commit), and the security architect is told. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt new file mode 100644 index 00000000..c63c7a69 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 3 correctly reserves the noreply domain across signup, reset, creation hooks, sign-in, and admin grants, with adequate tests. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md new file mode 100644 index 00000000..151bf581 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md @@ -0,0 +1,23 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Reserved-domain coverage is solid, but padded noreply sign-ups violate the explicit 409 contract. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 3, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES (one issue); gemini COMMENT (skipped, `agy` not installed). + +**Padded noreply sign-up returns 400, not 409: disagree with changing the code; the spec text is corrected instead.** + +- `signUpGuard` validates the email before the reserved/taken check. That is the legacy register order (validation → reserved or taken → strength), pinned by spec 300 and by apps/api's frozen `/v2/users` contract suite. Trimming before validation would change sign-up for every address, which is outside this spec and touches a frozen contract. +- The property the criterion protects is "no oracle". It holds: a padded noreply address, a padded taken address and a padded fresh address all get the identical 400 `INVALID_EMAIL`, and unpadded noreply and taken addresses get the identical 409. The test asserts both. +- The spec's "(any case, surrounding spaces) → 409" was my drafting error: the existing guard never gave 409 for a padded address, the system domain included. The spec criterion is amended to state the real behaviour (same commit), and the security architect is told. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt new file mode 100644 index 00000000..02b61a95 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 4 is largely complete, but the database target is not displayed before writes occur. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `serviceKeys()` performs `runServiceKeys()`—including database writes—before `main()` prints the buffered hostname line. This defeats the required pre-write operator warning. Emit `Auth database: hostname:port` before invoking any write, and test temporal ordering rather than only final line order. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md new file mode 100644 index 00000000..fd47f712 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md @@ -0,0 +1,15 @@ +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt new file mode 100644 index 00000000..229f09c3 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 4 is complete except the production Sentry configuration can leak newly issued keys through console breadcrumbs. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `main()` initializes Sentry with its default Console integration, prints the raw key via `console.log`, then captures the operator event. The raw key therefore becomes a breadcrumb attached to that event. The test disables default integrations, masking this leak. Disable/scrub console breadcrumbs for this script or write secret output outside instrumented console APIs, and test with production-shaped Sentry defaults. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md new file mode 100644 index 00000000..f2da509b --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md @@ -0,0 +1,29 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 4 is largely complete, but the database target is not displayed before writes occur. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md new file mode 100644 index 00000000..ee631068 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md @@ -0,0 +1,21 @@ +# Phase 4, iteration 2: response to review + +## Codex (REQUEST_CHANGES): the raw key could reach Sentry through console breadcrumbs + +**Accepted and fixed.** The leak was real. + +`main()` initialised Sentry with its default integrations and printed with `console.log`. The Console integration recorded the raw key as a breadcrumb, and that breadcrumb was attached to the operator event captured next. The existing test used `defaultIntegrations: false`, which hid the leak. + +Two independent guards: +- **Breadcrumbs are off.** The script now has its own Sentry options, `SCRIPT_SENTRY_OPTIONS`: `maxBreadcrumbs: 0`, `beforeBreadcrumb: () => null`, `sendDefaultPii: false`. They replace the shared `initSentry` in this script. +- **Output does not go through `console`.** The script prints through `printLine`, which calls `process.stdout.write`; Sentry does not instrument it. + +Test (`apps/auth/tests/service-keys-sentry.test.ts`): it uses production-shaped Sentry, meaning default integrations ON, the script's own options, and a spy transport only. Run 1, with the script's printer, asserts the key reaches stdout exactly once and appears in no envelope. Run 2 prints through `console.log` and asserts no envelope carries the key, so the breadcrumb guard holds on its own. + +Negative test: with the breadcrumb options removed, run 2 goes red (the key appears in the envelope) and run 1 stays green. That reproduces the reported leak and shows each guard is load-bearing. + +The full apps/auth suite passes (338). Typecheck and lint are clean, and the demo harness passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped again: `agy` exited with code 3. There is nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt new file mode 100644 index 00000000..e45aa132 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 4 meets the specified operator-script, security, Sentry, and test requirements. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md new file mode 100644 index 00000000..65fce706 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md @@ -0,0 +1,57 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 4 is largely complete, but the database target is not displayed before writes occur. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. + + +### Iteration 2 Reviews +- codex: REQUEST_CHANGES — Phase 4 is complete except the production Sentry configuration can leak newly issued keys through console breadcrumbs. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 2 +# Phase 4, iteration 2: response to review + +## Codex (REQUEST_CHANGES): the raw key could reach Sentry through console breadcrumbs + +**Accepted and fixed.** The leak was real. + +`main()` initialised Sentry with its default integrations and printed with `console.log`. The Console integration recorded the raw key as a breadcrumb, and that breadcrumb was attached to the operator event captured next. The existing test used `defaultIntegrations: false`, which hid the leak. + +Two independent guards: +- **Breadcrumbs are off.** The script now has its own Sentry options, `SCRIPT_SENTRY_OPTIONS`: `maxBreadcrumbs: 0`, `beforeBreadcrumb: () => null`, `sendDefaultPii: false`. They replace the shared `initSentry` in this script. +- **Output does not go through `console`.** The script prints through `printLine`, which calls `process.stdout.write`; Sentry does not instrument it. + +Test (`apps/auth/tests/service-keys-sentry.test.ts`): it uses production-shaped Sentry, meaning default integrations ON, the script's own options, and a spy transport only. Run 1, with the script's printer, asserts the key reaches stdout exactly once and appears in no envelope. Run 2 prints through `console.log` and asserts no envelope carries the key, so the breadcrumb guard holds on its own. + +Negative test: with the breadcrumb options removed, run 2 goes red (the key appears in the envelope) and run 1 stays green. That reproduces the reported leak and shows each guard is load-bearing. + +The full apps/auth suite passes (338). Typecheck and lint are clean, and the demo harness passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped again: `agy` exited with code 3. There is nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-codex.txt new file mode 100644 index 00000000..3c81032f --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-codex.txt @@ -0,0 +1,8 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Core implementation is strong, but signing after commit breaks the specification’s locking invariant. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `issueWhatsappToken` commits before `signJWT`. A concurrent ban, deletion, or admin promotion can commit between eligibility/session checks and signing, allowing issuance after promotion or producing a JWT for a deleted session. Signing must remain protected by the decision lock or the specification must be formally revised. +- The claim-equivalence test does not directly verify `definePayload` output or JWT `kid`, despite both being explicit phase deliverables. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-rebuttals.md new file mode 100644 index 00000000..3f2e5dd8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter1-rebuttals.md @@ -0,0 +1,24 @@ +# Phase 5, iteration 1: response to review + +## Codex (REQUEST_CHANGES) 1: signing after the commit breaks the locking invariant + +**Not changed: this is the approved plan, decided on probe evidence. The spec text drift is real and has been raised with the security architect.** + +Signing after the commit was adopted in plan iteration 2 (security review P1, `329-plan-iter2-rebuttals.md`) and is plan phase 5 step 7, approved at plan-approval. Signing inside the transaction holds a pooled connection while `signJWT` reads `jwks` on a second one. On real Postgres with a pool of 2, 10 concurrent exchanges then HANG with no connect timeout, or fail loudly with one (probe 4 E2, rerun today, in the review). Signing after the commit: 10/10 (E1, and F4 through the real functions). pglite shows the same hazard: a mutant that signs inside the transaction deadlocks its one connection. + +Moving the signature out of the transaction does not add a new class of outcome. The spec already accepts each one as a residual after signing: +- **A ban or deletion that commits between our commit and `signJWT`:** the ban deletes the sessions, so the token's `sid` is dead. apps/api's first use fails the live-sid check and the consumer re-exchanges, which is then refused. The spec accepts exactly this for a ban one millisecond after signing ("the token then fails apps/api's first use"). +- **A promotion to admin in that window:** with signing inside the transaction, a promotion that commits one millisecond after the commit leaves the holder with the same token in the same state. Admin authority is never in the token. It is `role` read at `/api/account/me` on every request, so the window is not an escalation path. The token carries only `{sub, sid}`, and the next exchange for that user is refused as `ineligible: admin`. +- **The invariant the spec states ("at signing time, the session outlives the token")** still holds: `expiresAt ≥ exp + SESSION_MARGIN` is checked under the lock against the same `exp` that is then signed. A session can only be deleted after the commit, never shortened. + +**Spec drift:** spec §2 step 5 still says "the signing run in that one transaction". The approved plan and the code disagree with that sentence. I have asked the security architect whether to amend the spec text to match. I have not edited the approved spec unilaterally. + +## Codex (REQUEST_CHANGES) 2: claim equivalence does not check `kid` or `definePayload` + +**Accepted (in part) and fixed.** The test now asserts that the exchange JWT's header `kid` is present and equal to the `/api/auth/token` JWT's. + +On `definePayload`: the `/token` JWT in that test is minted from the exchange's own session, and `/token` is better-auth's `getJwtToken`, which applies `definePayload`. So `{sub, sid}` equal to `/token`'s for the same session is a direct check of `definePayload`'s output. A comment now says so. 26/26 green. + +## Gemini + +Skipped again (`agy` exited with code 3). There is nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-codex.txt new file mode 100644 index 00000000..6d2e2530 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 5 matches the approved plan, with comprehensive pglite tests and real-Postgres concurrency validation. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-context.md new file mode 100644 index 00000000..b2773f69 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-context.md @@ -0,0 +1,38 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Core implementation is strong, but signing after commit breaks the specification’s locking invariant. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 5, iteration 1: response to review + +## Codex (REQUEST_CHANGES) 1: signing after the commit breaks the locking invariant + +**Not changed: this is the approved plan, decided on probe evidence. The spec text drift is real and has been raised with the security architect.** + +Signing after the commit was adopted in plan iteration 2 (security review P1, `329-plan-iter2-rebuttals.md`) and is plan phase 5 step 7, approved at plan-approval. Signing inside the transaction holds a pooled connection while `signJWT` reads `jwks` on a second one. On real Postgres with a pool of 2, 10 concurrent exchanges then HANG with no connect timeout, or fail loudly with one (probe 4 E2, rerun today, in the review). Signing after the commit: 10/10 (E1, and F4 through the real functions). pglite shows the same hazard: a mutant that signs inside the transaction deadlocks its one connection. + +Moving the signature out of the transaction does not add a new class of outcome. The spec already accepts each one as a residual after signing: +- **A ban or deletion that commits between our commit and `signJWT`:** the ban deletes the sessions, so the token's `sid` is dead. apps/api's first use fails the live-sid check and the consumer re-exchanges, which is then refused. The spec accepts exactly this for a ban one millisecond after signing ("the token then fails apps/api's first use"). +- **A promotion to admin in that window:** with signing inside the transaction, a promotion that commits one millisecond after the commit leaves the holder with the same token in the same state. Admin authority is never in the token. It is `role` read at `/api/account/me` on every request, so the window is not an escalation path. The token carries only `{sub, sid}`, and the next exchange for that user is refused as `ineligible: admin`. +- **The invariant the spec states ("at signing time, the session outlives the token")** still holds: `expiresAt ≥ exp + SESSION_MARGIN` is checked under the lock against the same `exp` that is then signed. A session can only be deleted after the commit, never shortened. + +**Spec drift:** spec §2 step 5 still says "the signing run in that one transaction". The approved plan and the code disagree with that sentence. I have asked the security architect whether to amend the spec text to match. I have not edited the approved spec unilaterally. + +## Codex (REQUEST_CHANGES) 2: claim equivalence does not check `kid` or `definePayload` + +**Accepted (in part) and fixed.** The test now asserts that the exchange JWT's header `kid` is present and equal to the `/api/auth/token` JWT's. + +On `definePayload`: the `/token` JWT in that test is minted from the exchange's own session, and `/token` is better-auth's `getJwtToken`, which applies `definePayload`. So `{sub, sid}` equal to `/token`'s for the same session is a direct check of `definePayload`'s output. A comment now says so. 26/26 green. + +## Gemini + +Skipped again (`agy` exited with code 3). There is nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_5-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-codex.txt new file mode 100644 index 00000000..17c94778 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 6 is comprehensive, but the boot assertion does not reliably verify the required account constraint. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `packages/auth/src/db.ts:hasAuthUniqueConstraint` checks only schema, constraint name, and type. PostgreSQL permits the same constraint name on another relation, and the query never verifies `auth.account` or the `(provider_id, account_id)` columns. Thus boot can start while the required constraint is absent. Bind the catalog check to the table and columns, with a spoofed-constraint test. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-rebuttals.md new file mode 100644 index 00000000..b383d478 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter1-rebuttals.md @@ -0,0 +1,16 @@ +# Phase 6, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the boot check does not bind the constraint to `auth.account` or its columns + +**Accepted and fixed.** `hasAuthUniqueConstraint(db, table, name, columns)` now requires the unique constraint `name` to be on `auth.` and to cover exactly `columns`, in order (from `pg_constraint.conkey` with ordinality, joined to `pg_attribute`). `assertSchema` passes `account`, `account_provider_id_account_id_unique` and `['provider_id', 'account_id']`. The columns go in as one parameter (`string_to_array`), not as interpolated SQL. + +New tests in `apps/auth/tests/boot.test.ts`. The real constraint is dropped, then a spoof is created; each case must refuse: +- the same name on another `auth` table (`auth.decoy`); +- the same name on `auth.account` over other columns (`account_id, user_id`); +- the same name on `auth.account` with the right columns in the other order. + +Negative control: with the old name-only query restored, all three fail (3 failed, 6 passed); with the fix, 9/9 pass. The production-shaped run on Postgres 18.6 was rerun with the new query: 13/13, boot check true on the full schema (step 1a). + +## Gemini + +Skipped (agy exit 3), non-blocking, as in earlier phases. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-codex.txt new file mode 100644 index 00000000..79cd2ae6 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 6 matches the plan; the corrected boot assertion now validates the exact auth.account constraint and is well tested. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-context.md new file mode 100644 index 00000000..e20297a2 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-context.md @@ -0,0 +1,30 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 6 is comprehensive, but the boot assertion does not reliably verify the required account constraint. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 6, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the boot check does not bind the constraint to `auth.account` or its columns + +**Accepted and fixed.** `hasAuthUniqueConstraint(db, table, name, columns)` now requires the unique constraint `name` to be on `auth.
` and to cover exactly `columns`, in order (from `pg_constraint.conkey` with ordinality, joined to `pg_attribute`). `assertSchema` passes `account`, `account_provider_id_account_id_unique` and `['provider_id', 'account_id']`. The columns go in as one parameter (`string_to_array`), not as interpolated SQL. + +New tests in `apps/auth/tests/boot.test.ts`. The real constraint is dropped, then a spoof is created; each case must refuse: +- the same name on another `auth` table (`auth.decoy`); +- the same name on `auth.account` over other columns (`account_id, user_id`); +- the same name on `auth.account` with the right columns in the other order. + +Negative control: with the old name-only query restored, all three fail (3 failed, 6 passed); with the fix, 9/9 pass. The production-shaped run on Postgres 18.6 was rerun with the new query: 13/13, boot check true on the full schema (step 1a). + +## Gemini + +Skipped (agy exit 3), non-blocking, as in earlier phases. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_6-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-codex.txt new file mode 100644 index 00000000..ff233413 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-codex.txt @@ -0,0 +1,17 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 7 is complete except the required operator documentation omits the accepted key-limit bounds. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `docs/self-hosting.md` and the runbook name `WHATSAPP_KEY_BOUNDS` but never document its actual ranges: rate max 1–100,000, window 1 second–24 hours, and daily budget 1–10,000,000. This misses the phase-7 documentation criterion. The doc test only checks that the constant exists, so it does not catch the omission. + +--- + +## ⚠️ WORKING TREE CHANGED during the codex review (#1649). +A consultant reviews the tree; it must never write to it. 2 paths changed while this lane was running: + - codev/projects/329-apps-auth-service-api-keys-bet/.codex-run.log + - codev/projects/329-apps-auth-service-api-keys-bet/.gemini-run.log +Check `git status` and `git diff` before trusting this review or shipping the branch. +Note: under cmap the lanes run concurrently in one worktree, so another lane — or you — +may be the writer. This warning names what moved, not who moved it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-gemini.txt new file mode 100644 index 00000000..c9057a90 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-gemini.txt @@ -0,0 +1,20 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. + +--- + +## ⚠️ WORKING TREE CHANGED during the gemini review (#1649). +A consultant reviews the tree; it must never write to it. 2 paths changed while this lane was running: + - codev/projects/329-apps-auth-service-api-keys-bet/.codex-run.log + - codev/projects/329-apps-auth-service-api-keys-bet/.gemini-run.log +Check `git status` and `git diff` before trusting this review or shipping the branch. +Note: under cmap the lanes run concurrently in one worktree, so another lane — or you — +may be the writer. This warning names what moved, not who moved it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-rebuttals.md new file mode 100644 index 00000000..209d61e3 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter1-rebuttals.md @@ -0,0 +1,26 @@ +# Phase 7, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the docs name `WHATSAPP_KEY_BOUNDS` but not its ranges + +**Accepted and fixed.** The phase asked for the `limits.ts` bounds, and the docs gave only the +constant's name. The test checked only that the constant exists, so it could not catch this. + +- `docs/self-hosting.md` now states the inclusive ranges: `--rate-max` 1 to 100,000, + `--rate-window` 1 second to 24 hours, `--daily` 1 to 10,000,000. +- `docs/cutover/service-keys-runbook.md` (Limits) has a table of the accepted ranges per flag, + and says that an out-of-range value is refused with nothing written. +- Tests compute the expected text from `limits.WHATSAPP_KEY_BOUNDS` instead of checking that + the key exists: `self-hosting-docs.test.ts` ("states limits.ts's values as they are") and a + new `cutover-docs.test.ts` test ("gives the script's accepted ranges as WHATSAPP_KEY_BOUNDS + defines them"). +- Negative run: a wrong upper bound in each doc (`--daily` 1,000,000 in the runbook, + `--rate-max` 10,000 in self-hosting) → both tests red (2 failed, 115 passed); restored → + 117 passed. + +The "working tree changed" notice in both reviews named only +`codev/projects/329-*/.codex-run.log` and `.gemini-run.log`: the stdout logs of my own +background consult run, not consultant writes. They are deleted and not committed. + +## Gemini (COMMENT) + +The lane was skipped (agy exit 3), as in every earlier phase. Non-blocking; nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-codex.txt new file mode 100644 index 00000000..675260f6 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 7 deliverables are complete, including the corrected documented key-limit bounds and code-derived tests. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-context.md new file mode 100644 index 00000000..2dfa39c1 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-context.md @@ -0,0 +1,40 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 7 is complete except the required operator documentation omits the accepted key-limit bounds. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 7, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the docs name `WHATSAPP_KEY_BOUNDS` but not its ranges + +**Accepted and fixed.** The phase asked for the `limits.ts` bounds, and the docs gave only the +constant's name. The test checked only that the constant exists, so it could not catch this. + +- `docs/self-hosting.md` now states the inclusive ranges: `--rate-max` 1 to 100,000, + `--rate-window` 1 second to 24 hours, `--daily` 1 to 10,000,000. +- `docs/cutover/service-keys-runbook.md` (Limits) has a table of the accepted ranges per flag, + and says that an out-of-range value is refused with nothing written. +- Tests compute the expected text from `limits.WHATSAPP_KEY_BOUNDS` instead of checking that + the key exists: `self-hosting-docs.test.ts` ("states limits.ts's values as they are") and a + new `cutover-docs.test.ts` test ("gives the script's accepted ranges as WHATSAPP_KEY_BOUNDS + defines them"). +- Negative run: a wrong upper bound in each doc (`--daily` 1,000,000 in the runbook, + `--rate-max` 10,000 in self-hosting) → both tests red (2 failed, 115 passed); restored → + 117 passed. + +The "working tree changed" notice in both reviews named only +`codev/projects/329-*/.codex-run.log` and `.gemini-run.log`: the stdout logs of my own +background consult run, not consultant writes. They are deleted and not committed. + +## Gemini (COMMENT) + +The lane was skipped (agy exit 3), as in every earlier phase. Non-blocking; nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_7-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-codex.txt new file mode 100644 index 00000000..a4450d9b --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: COMMENT +SUMMARY: Phase 8 implementation is complete, but the review summary still describes the withdrawn phone-storage design. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Update `codev/reviews/329-apps-auth-service-api-keys-bet.md`: its summary says seven phases, and its compliance checklist still claims `auth.users.phone_number` exists and is populated. These contradict Phase 8 and the current schema. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_8-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt new file mode 100644 index 00000000..3460eaa8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt @@ -0,0 +1,20 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Strong structure and coverage, but several security-critical implementation details remain unresolved. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Limit values remain provisional, contrary to the spec constraint that owner decision 3 be finalized from traffic data. Obtain the data or explicit owner approval for final fallback values. +- Mounting before CORS is insufficient unless the exchange router terminates `OPTIONS`; otherwise it falls through to `corsFor`. Specify an explicit no-CORS `OPTIONS` handler. +- Specify route-local JSON error middleware mapping malformed and oversized bodies to the exact 400 contract without Express error pages or body leakage. +- `issueWhatsappToken` receives only `userId`, so it cannot re-check that the sole account is the exact WhatsApp/BSUID row resolved for this request. Pass the expected BSUID/account identity and verify it under the transaction. +- Operator-script Sentry events may be lost on process exit. Require Sentry initialization and awaited `flush`/`close` after create, rotate, and revoke. +- Add explicit tests for expired bans, delegation inputs (admin/email IDs and emails, Google account IDs), per-user rate-limit key contents, and confirmation that every key refusal creates no identity/session rows. + +--- + +## ⚠️ REPOSITORY MOVED during the codex review (#1649). +A consultant reviews the commit in front of it; it must never commit, or switch refs. + - HEAD: 2a60f246a8d670f5e02af865197cec5be6139820 -> cd1ebb170bb3ba3a84ee43d7791c5b0e25fd3335 +The files on disk may look untouched and still not be the ones this review describes. +Check `git reflog` and `git status` before trusting this review or shipping the branch. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md new file mode 100644 index 00000000..1a0b12ab --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md @@ -0,0 +1,12 @@ +# Plan 329, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Provisional limits: resolved by the owner after the review started. Traffic is low, one instance, no numbers; size by judgement. The plan now carries initial values (the decision), alerts at one third of the caps, wide bounds and an `update-limits` path (commit cd1ebb1). The spec's decision 3 is updated to match. +2. CORS via OPTIONS fall-through: accepted. The router terminates every request to its path; non-POST methods, OPTIONS included, get 404 with no CORS headers and never call `next()`. Tests added. +3. JSON parser errors: accepted. A route-local error handler maps parse, size and content-type errors to the exact 400 body. Tests added. +4. Eligibility binds the BSUID: accepted. `issueWhatsappToken` takes the BSUID and requires the sole account row's `account_id` to equal it under the lock. A re-pointed-row case is added to the matrix. +5. Sentry flush in scripts: accepted. Initialise, await `flush` before exit, report a failed flush. +6. Missing tests: accepted. Expired ban (phases 5 and 6), delegation inputs over HTTP, rate-limit key contents (no BSUID or phone), and unchanged row counts after every key refusal. + +Repository-moved warning: the HEAD change was my own commit (cd1ebb1, owner decision 3), made while the review ran. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-codex.txt new file mode 100644 index 00000000..a5962a5f --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-codex.txt @@ -0,0 +1,11 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 8 covers §5 broadly, but key dependency-injection and concurrency details remain underspecified. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `packages/auth/src/phone.ts` cannot directly emit the required Sentry count: `@ansari/auth` has no Sentry dependency and Sentry is owned by `apps/auth/src/alerts.ts`. Specify an injected alert callback through `AuthExtensions`/`createServiceAuth`, including a distinct notice-failure alert. +- Claim logic must explicitly handle a phone holder already having a `whatsapp` row. The existing schema enforces `unique(user_id, provider_id)`, so blindly claiming causes a constraint error rather than the required typed refusal/alert. The “second WhatsApp row” test also needs a realistic strategy given that constraint. +- Define how the five routes’ normalized `phoneNumber` is written back into Better Auth’s request context before its exact-value lookups. Hash-only normalization permits storage, lookup, and budget keys to disagree. +- Strengthen race assertions beyond “one holder”: verify returned outcomes, final users/accounts, session revocation, alerts, notices, and token validity so retry-driven supersede chains cannot pass. +- Add the concrete wiring files to Phase 8: `apps/auth/src/index.ts`, service test helpers, `createPerimeterHooks`, and `ServiceDeps` construction must all carry the mailer and phone-alert callbacks. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-context.md new file mode 100644 index 00000000..dfbadea9 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-context.md @@ -0,0 +1,32 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Strong structure and coverage, but several security-critical implementation details remain unresolved. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Plan 329, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Provisional limits: resolved by the owner after the review started. Traffic is low, one instance, no numbers; size by judgement. The plan now carries initial values (the decision), alerts at one third of the caps, wide bounds and an `update-limits` path (commit cd1ebb1). The spec's decision 3 is updated to match. +2. CORS via OPTIONS fall-through: accepted. The router terminates every request to its path; non-POST methods, OPTIONS included, get 404 with no CORS headers and never call `next()`. Tests added. +3. JSON parser errors: accepted. A route-local error handler maps parse, size and content-type errors to the exact 400 body. Tests added. +4. Eligibility binds the BSUID: accepted. `issueWhatsappToken` takes the BSUID and requires the sole account row's `account_id` to equal it under the lock. A re-pointed-row case is added to the matrix. +5. Sentry flush in scripts: accepted. Initialise, await `flush` before exit, report a failed flush. +6. Missing tests: accepted. Expired ban (phases 5 and 6), delegation inputs over HTTP, rate-limit key contents (no BSUID or phone), and unchanged row counts after every key refusal. + +Repository-moved warning: the HEAD change was my own commit (cd1ebb1, owner decision 3), made while the review ran. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration + +### Builder note for this round (2026-10-30): what is new +Phases 1-7 were implemented and approved, and the PR (#331) reached its gate. The owner then reopened the project (porch rolled back review -> plan). The new material to review: +- spec §5, "Owner amendment (2026-10-30)": better-auth's phone-number plugin columns in migration 0001 (in place; not applied anywhere); the plugin enabled and unblocked, with a stub OTP sender, one 4xx body and per-number budgets; verified-by-construction numbers; in the exchange, claim / supersede / refuse when a number is already held; widened eligibility; link notice; unlink; the residual. Commits 1d84f91d and ca1452db. +- plan "Phase 8" (commit d3b92b00), which implements §5. +Please review phase 8 against spec §5. Phases 1-7 are the record of shipped work. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md new file mode 100644 index 00000000..876410eb --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md @@ -0,0 +1,52 @@ +I'll start by reading both documents from disk and verifying the codebase facts they rely on.## Review — plan 329 against the approved spec + +Read directly from disk in `.builders/spir-329`: the plan, the spec, and every file the plan touches or reuses (`packages/auth/src/{index,limits,rate-limit-store,schema,db,reserved,users}.ts`, `apps/auth/src/{index,app}.ts`, `apps/auth/src/middleware/blocked-paths.ts`, `apps/auth/src/routes/{account,health}.ts`, `apps/auth/src/alerts.ts`, `apps/auth/scripts/grant-admin.ts`, the test harnesses, and better-auth 1.7.7's dist for `jwt/sign.mjs`, `jwt/index.mjs`, `db/internal-adapter.mjs`, `api/rate-limiter/index.mjs`). Nothing was modified; all commands were read-only. + +### Library behaviour I am relying on that neither document records as probed + +State this first because three findings below depend on it. `@better-auth/api-key@1.7.7` **is not installed** — `packages/auth/node_modules/@better-auth/` contains only `expo`. So the plugin's 7-endpoint/5-routed surface, `startingCharactersConfig.shouldStore:false`, `defaultKeyHasher`'s SHA-256/base64url output, `updateApiKey`'s field semantics, and the collapse of driver errors into `{valid:false, INVALID_API_KEY}` rest entirely on the spec's recorded specify-phase pglite probe plus the plan's phase-1 pinning tests. I could not independently verify any of them. Separately, the plugin's outage-vs-refusal classification depends on `APIError instanceof` working across a *single* copy of `better-call`; the repo resolves `better-call@1.4.0` singly today and the plugin peers `better-call 1.4.0`, so this holds now, but **nothing pins it** — a second copy arriving via a transitive bump turns every outage into a refusal (fail-open on the alerting side) with no test failing. + +--- + +### Blocking + +**1. `signJWT` inside the `FOR NO KEY UPDATE` transaction starves the connection pool, and the spec's timeouts do not bound it.** +The plan signs the JWT inside the locking transaction. That transaction holds one pg connection for its whole life; `auth.api.signJWT` → `resolveSigningKey` queries the `jwks` table through the drizzle adapter on the *same* `Pool` on **every** call, and because the plan uses a raw `db.transaction` rather than better-auth's `runWithTransaction`/`getCurrentAdapter`, the adapter is not bound to the open transaction — it acquires a **second** connection. `createDb()` is `new Pool({ connectionString })` with no `max` and no `connectionTimeoutMillis`, i.e. pg's defaults: 10 connections and an **unbounded** acquisition wait. At ~5–10 concurrent exchanges every connection is held by a transaction waiting for a second connection that can never be issued, and the pool is shared with sign-in, JWKS and `/api/account/*` — so the failure mode is a hang of the whole service, not a slow exchange. `lock_timeout` (55P03) and `statement_timeout` bound *lock waits* and *statement* duration; neither fires while a caller is blocked in `pool.connect()`, which is precisely the gap the spec claims those timeouts close. The spec's note that "signJWT does no database write" is also wrong on the auto-mint path: `resolveSigningKey` calls `createJwk` when no live unexpired key exists. Fix shape: resolve the identity and mint the session inside the transaction, **commit**, then sign outside it — or bind the adapter with `runWithTransaction` and set an explicit `max`/`connectionTimeoutMillis` on the pool. Either way this needs a phase-1 probe (N concurrent exchanges against real Postgres with a deliberately small `max`), because a single-request test passes under the broken design. + +**2. `generateId` is not reachable from `packages/auth`.** +The plan sources the 32-char session token from `generateId(32)`. In 1.7.7 that function lives in `@better-auth/core/utils/id` and is **not** re-exported from the `better-auth` main entry, and `@better-auth/core` is not a declared dependency of `packages/auth` — under pnpm's isolated layout the import will not resolve, so phase 5 cannot build as written. This is cheap to fix (declare `@better-auth/core@1.7.7`, matching the api-key plugin's peer, and deep-import deliberately; or take the spec's `internalAdapter.createSession` alternative, which also gets `token`, `ipAddress:""`, `userAgent:""` for free). I tag it blocking rather than should-fix because the thing being generated is a 30-day bearer credential: a silent fallback to any weaker generator is a security defect, so whichever source is chosen needs a test pinning the token's length and alphabet, not just "it compiles". + +--- + +### Should-fix + +**3. The daily exchange alert is a check that can pass by not running.** +better-auth's DB rate limiter prunes with `deleteExpiredRows(now)`, deleting every row in the rate-limit table whose `lastRequest < now - longestObservedWindow*1000`. The repo feeds that from `rateLimitRetention`'s `LONGEST_WINDOW`, which `limits.ts` computes as the max over a **hand-maintained** array and is 3600s today. The plan specifies a per-key daily counter (1,000/day) but never says where it is stored; if it lands in `auth.rate_limit` like every other counter, it is deleted after one hour of key inactivity — and under the plan's own stated low-traffic posture, hour-long gaps are the normal case, so the daily alert can never fire. Note the trap in the obvious fix: adding 86400 to that array raises retention for the *entire* table, including `sub:` and `alert|` rows. The new per-user 900s window is safe; the daily one is not. The plan should name the storage, and either justify a separate counter outside the pruned table or state and accept the retention change — with a test that advances a clock past the prune cutoff and shows the daily counter survives. + +**4. Nothing pins the single-`better-call` assumption that the outage/refusal split rests on.** +See the disclosure above. A `packages/auth` test asserting exactly one resolved `better-call` version (or that the plugin's thrown error is `instanceof` the `APIError` the service imports) costs one assertion and converts a silent fail-open into a build failure. Phase 1 is the right home, since every later phase's error mapping depends on it. + +**5. Phase 7's arch.md update should record the apps/api-side constraint, not just the apps/auth-side design.** +`apps/api/lib/auth/resolve-user.ts:129` refuses first use only for `isSystemAddress(email)` — the *system* domain. WhatsApp users carry the new noreply domain and therefore pass first use, which the exchange requires. That is load-bearing and invisible from either document: a future tightening of that check to "any reserved domain" breaks WhatsApp with no failing test in this spec's suite. Record it as an invariant in arch.md and add a one-line test in apps/api asserting a noreply-domain user resolves on first use. + +--- + +### Nits + +**6. `session.updatedAt` has no DB default.** The schema declares it `.notNull()` with `$onUpdate` only, so a direct insert that omits it fails at runtime, not at type-check. Trivial, but the plan's phase-5 insert list should name it explicitly. + +**7. "Indistinguishable from a better-auth session" is literally false.** `internalAdapter.createSession` writes `""` for absent `ipAddress`/`userAgent`; a hand-rolled insert writing `null` is distinguishable by a trivial query. The spec sanctions the hand-rolled path, so this is cosmetic — but if the claim is going to stand in docs, write `""` to make it true. + +**8. The parity test needs more than the journal-index edit.** `packages/auth/tests/migration/auth-journal-parity.test.ts` asserts `journal.map(e => e.idx)).toEqual([0])` *and* hard-codes the six-table list. The plan names the first edit but not the second; the `apikey` entry must be added or phase 1 leaves the tree red. + +**9. The new runbook must avoid the apply-record pattern.** While `AUTH_DEPLOYED_THROUGH` is `null`, that same test asserts `recordedApplies()` equals `[]`, scanning `RELEASE.md` and every `docs/**/*.md` for `^auth-journal-applied:\s*(\d{4}_[a-z0-9_]+)$`. A `docs/cutover/service-keys-runbook.md` that shows the apply line as a bare example — rather than inside backticks, which the pattern already rejects — turns phase 7 red for a documentation reason. Worth one sentence in the phase. + +**10. `users.ts`'s stale header line has ambiguous phase ownership.** The plan's "fix it once phase 6 adds one (or here, worded for phase 6)" leaves it owned by nobody. Assign it to phase 6 or drop it. + +--- + +### Dimensions that came back clean + +Coverage and ordering are otherwise sound: every success criterion maps to a phase with a named test, and the probe ordering is right — the unproven-library probes sit in phase 1 ahead of everything built on them, and the plan states the fallback when each fails. Phase independence holds apart from findings 2 and 8. No spec contradictions found: the plan's choice to mount the exchange *before* `corsFor` and terminate every request is **stronger** than the spec, and correct — `createApp` currently puts `corsFor` first, so the spec's wording alone would have leaked CORS headers onto the exchange. I verified and found no weakening of the never-log lists, the single byte-identical 401, the relation check and its TTL, the 42P01-only boot assertion, the per-user budget's placement outside the transaction, or claim-set equality (`jwt/sign.mjs` honours an explicit `exp` and sets `iat` only when passed, so equality with `/api/auth/token` is achievable as described). Migration/deploy posture is right: 0001-numbered-by-main, migration-before-code, the pre-apply duplicate check, and the ACCESS EXCLUSIVE note on the unique constraint are all present. `errorMeta` and `urlHost` reuse are accurate precedents, `SENTRY_DSN` already exists so "no new env var" holds, no `isReservedAddress` logic change is needed in either the `user.create.before` hook or `signUpGuard`, and every reused harness (`startService`, the apps/api contract harness, the source-scan and docs tests) exists as cited. No scope creep. + +REQUEST CHANGES (1: move `signJWT` out of the locking transaction or bind the adapter, bound the pool, and probe concurrency; 2: make the session-token generator resolvable and pin its shape by test; 3: name the daily counter's storage so pruning cannot silence it; 4: pin the single-`better-call` assumption; 5: record the apps/api noreply-domain invariant; 6–10: nits) \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md new file mode 100644 index 00000000..0ec05fdf --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md @@ -0,0 +1,39 @@ +## Findings + +1. **Blocking — Probe failure gates occur too late.** Phase 1’s manual failure rule stops only before Phase 5 (plan lines 115, 406), but Phase 4 already depends on `createApiKey` and `updateApiKey`. Each probe needs an explicit fail-closed gate before its first consumer: key probes before Phase 4, JWT/field/lock probes before Phase 5, and logger/outage-mapping before Phase 6. The current wording could permit building Phase 4 after a contradicted key probe. + +2. **Blocking — Success criteria do not have unique phase ownership.** There is no traceability matrix, and several criteria are duplicated while others lack a named test. Claim equality is tested in Phases 1 and 5; expired-ban behavior in Phases 5 and 6; raw-key hashing in Phases 1 and 4. Missing or incomplete named tests include: + - list/rotate/revoke output fields required by spec line 239; + - every script/subcommand’s database URL, confirmation, hostname, and key-id behavior (line 240); + - zero database queries for missing/malformed bearers (line 258); + - `UserGone` mapped through HTTP to 500 (line 260); + - a pre-ban JWT subsequently failing `/api/account/me` (line 274); + - exhaustive body sanity cases, rather than “body cases” (plan line 334); + - captured Sentry events excluding the phone number (line 290); + - alert one-event-per-window behavior (line 297). + + Add a criterion → owning phase → named test table, distinguishing early probes from the single acceptance-test owner. + +3. **Blocking — Security requirements are weakened in several places.** + - The boot test checks `{name, code}` only for `42P01`; the missing-constraint refusal also requires that log shape (spec line 234, plan lines 347–349). + - The plan says exchange alerts carry “counts and windows only” (plan line 318), while the approved alert posture includes counts, windows, and hash prefixes (spec lines 295 and 325). Either preserve that posture or explicitly amend the spec. + - The Sentry capture test omits phone-number exclusion (plan line 343 versus spec line 290). + - The script test says only “the Sentry event,” rather than independently proving create, rotate, and revoke each emit exactly one event with all required fields (spec line 244). + +4. **Blocking — The promised durable operator audit record is not guaranteed.** The plan lets an action succeed when `Sentry.flush` fails, leaving only terminal output (plan line 213). That contradicts the durable-event requirement at spec line 98. Define the intended durability contract: fail the write before execution, persist an outbox/audit row, or amend the approved requirement. Merely reporting failed delivery on stderr is not durable. + +5. **Should-fix — Per-phase green status is underspecified.** Phase 1 names only the two auth package suites plus lint/typecheck, while root `turbo run test` appears only in Phase 7. “All suites pass” elsewhere is ambiguous. Since every phase is a commit, each should name the same root green command or a justified complete affected-package set. Phase 4’s instruction to run CLI subcommands manually against a “pglite-backed” database also needs a concrete runnable harness; the production CLI otherwise expects a PostgreSQL URL. + +6. **Should-fix — The plan explicitly permits an existing-test edit forbidden by the spec.** Plan line 146 allows Better Auth log-output tests to be edited and merely listed in the commit message. Spec line 330 permits existing-test edits only for blocked-path, schema, and reserved-check enumerations. Remove that allowance or amend the specification. + +7. **Should-fix — Wrong-content-type handling describes the wrong library mechanism.** I verified the installed `body-parser@2.3.0`: an unsupported media type is normally skipped by `express.json`, not raised as an `entity.parse.failed`/`entity.too.large` parser error. Validation can still produce the required 400, and the HTTP test should pin it, but the implementation must not depend on the route-local parser error handler seeing this case. This is the one place my review relies on library behavior neither document records as probed. + +## Migration, deployment, and scope + +The plan adequately covers `0001` numbering by main, generated-and-reviewed SQL, migration-before-code documentation, the duplicate pre-check, and the `ACCESS EXCLUSIVE` quiet-window warning. It does not introduce `db:push`. + +No material scope creep was found. The daily alert and the additional real-Postgres session-insert check are reasonable hardening; `update-limits` is already required by the approved specification. + +The highlighted requirements for the one 401 body, relation check with TTL, CORS-before-`corsFor`, pool-level per-user budget, claim equality, and lock/statement timeouts are otherwise retained. + +**REQUEST CHANGES (probe gates; criterion ownership and missing tests; security assertions; durable Sentry record; per-phase green commands; existing-test contradiction)** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-claude.md new file mode 100644 index 00000000..f1148ac8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-claude.md @@ -0,0 +1,51 @@ +I'll ground this against the actual code before judging. Let me read the repo and the plugin source.Critical plugin behaviour found. Let me check the plugin's defaults/schema and the repo's own auth config.One more check: whether apps/api's legacy layer forwards better-auth's sign-in response (which would now carry the number).## Review — spec 329 §5 (owner amendment) and plan phase 8 + +Everything below is grounded in better-auth 1.7.7's own dist in this worktree (`dist/plugins/phone-number/{routes,index,schema}.mjs`, `dist/api/dispatch.mjs`, `dist/api/rate-limiter/index.mjs`, `better-call/dist/to-response.mjs`) and in the phase-7 code the phase changes in place. Where a conclusion depends on behaviour the documents do **not** record as probed, I say so; the plan's Risks section records probes only for (a) known-vs-unknown bodies and (b) whether `verify` writes through `user.update`. + +### Blocking + +**B1 — The stub sender does not close the OTP surface; it opens a guessable one. (1)(5)** +`/phone-number/send-otp` creates the verification row *before* it calls `sendOTP` (`routes.mjs:156-161`, then `162-173`) and nothing deletes it when the sender throws. Every refused send therefore commits a live 6-digit code (`otpLength` 6, `expiresIn` 300s — `index.mjs:11-12`) keyed by the raw number, and each send creates a **new** row, so the guess budget is `3 × sends` rather than 3. `/phone-number/verify` is unblocked, consumes that row (`allowedAttempts` defaults to 3, `routes.mjs:503`), and on success sets `phone_number_verified` and **creates a session with a session cookie** for whoever holds the number (`routes.mjs:380-392`) — a full first-class session, not an exchange JWT, on a claimed email/social account. So "there is no sender yet" does not mean "no OTP exists"; it means an OTP exists that nobody can receive and anybody can guess. The fix is small and consistent with the owner's "enable the plugin and don't block it": keep the plugin enabled, keep the paths out of `disabledPaths`, and refuse the five routes **in a before hook** so nothing is written and nothing is guessable — which is exactly the single 4xx §5.2 already promises. Set `allowedAttempts: 1` as defence in depth. + +**B2 — `request-password-reset` never reaches the stub, answers 200, and its partner mints a credential row. (1)(5)(7)** +That route uses the separate `sendPasswordResetOTP` option (`routes.mjs:433`), which the phase does not set; with it unset the route still creates a reset OTP for `-request-password-reset` and returns `{status:true}` — no stub call, no Sentry count, no typed error, no 4xx. `/phone-number/reset-password` then, on a correct OTP, **creates a `credential` account row when the user has none** and sets the password (`routes.mjs:475-480`). Three consequences: the amendment's "`sendOTP` is the one stub and every refusal is one 4xx" is false for two of the five routes; a WhatsApp-only holder can be handed a credential row and a password, which is both a takeover and a silent reclassification from *supersede* to *claimable* in §5.4; and `revokeSessionsOnPasswordReset: true` kills the exchange's session while the attacker keeps the account. Same fix as B1, plus an explicit pin that `sendPasswordResetOTP` is unset **and** the route cannot execute. + +**B3 — Uniqueness is per-string, not per-number, so the claim/supersede decision can simply miss the holder. (1)(4)** +The plugin stores `ctx.body.phoneNumber` verbatim (`routes.mjs:340`, `369`); `phoneNumberValidator` is unset; the exchange stores Meta's `wa_id`, which carries no `+`. `phone.ts`'s normalisation is described only as "the normalisation the exchange and the budgets share" — not as something applied to what the plugin writes. `15551234567` and `+15551234567` therefore coexist happily under `UNIQUE`, and every decision in §5.4 is an exact-match lookup that takes the "held by nobody" branch. The whole amendment — one holder, verified by construction, claim vs supersede vs conflict — rests on an index that cannot see through formats. Canonicalise the body in the before hook (`signUpGuard` already shows the `return { context: { body } }` seam) and/or set `phoneNumberValidator` to refuse non-canonical input, then test both representations of one number against each branch. + +**B4 — Supersede's predicate reaches an admin before the refuse branch does. (1)** +§5.4 states supersede as "the holder has no sign-in method other than WhatsApp", with role and `service` in a *separate* later bullet; the phase restates the three branches in that order and drops even the "(a user the exchange created)" parenthetical. A WhatsApp user later promoted to admin — the spec's own seeded case, since `grant-admin.ts` refuses noreply addresses — matches supersede's predicate, so a leaked key clears an admin's number and **revokes an admin's sessions**; a WhatsApp-only `service` holder is the same shape. Worse, the refuse test as specified ("a holder that is an admin") will most naturally seed an ordinary admin *with* a credential row, which the claim branch's guard already rejects: the test passes while the dangerous case stays uncovered. Make `role = 'user' ∧ no service row` a precondition of **both** claim and supersede, evaluated first, and name the WhatsApp-only-admin and WhatsApp-only-service holders in the §5.6 matrix. + +### Should-fix + +**S5 — Counts-only alerts make the documented repair impossible, and the phase contradicts itself. (4)(7)** Unlink is specified "with the find-by-log-line step first", yet `whatsapp-linked` and `number-superseded` are counts-only and nothing else records who was involved. The established posture for eligibility violations is *count event plus a server log line carrying the user id*; apply it to both new kinds (the linked account's id; the superseded and new user ids), never the number or the BSUID. Without it, an operator who sees `number-superseded` cannot find the orphaned user the spec tells them to repair. + +**S1 — A one-body mapping in `hooks.after` cannot control the status. (2)(7)** When an endpoint throws, dispatch sets `result.status = e.statusCode`; when an after hook replaces the response it updates `result.response` only (`dispatch.mjs:238-247`), and `toResponse` prefers `init.status` over the replacement error's own (`to-response.mjs`, `isAPIError` branch). An after-hook mapping therefore returns the canonical body under the *original* status — 400 `INVALID_OTP`, 403 `TOO_MANY_ATTEMPTS`, 401 sign-in, 500 for the stub's non-`APIError` throw — and "byte-identical" passes only if the test ignores the status line. Name the seam (before hook or Express wrapper) and compare status + headers + body. + +**S2 — The known-vs-unknown criterion can pass by not running. (2)(7)** If seeding the "known" number silently fails — and today the only way to seed a verified number is the exchange, which B3 can defeat — both halves of the comparison are unknown numbers and the assertion is vacuous. Require a `SELECT` proving the seeded holder exists before comparing, and an assertion that the route is reachable rather than returning the 404 a blocked path gives. + +**S3 — Claim and supersede write before the ban and budget are checked. (4)** `resolveOrCreateWhatsappUser` runs before `decideUnderLock`, so the `whatsapp` row is inserted on the holder (or the holder superseded) before `banned` is read and before `consumeWindow` spends the per-user budget. A banned claimable holder gets permanently linked and then answers 403: a write kept on a refused request, and an unlink nobody knows to perform. Check `banned` on the holder under its own lock before linking, or state and test that linking a banned holder is intended. (Related: the claim path spends the *victim's* exchange budget.) + +**S4 — No lock order, and the phone UPDATE's unique violation is not the create path's. (4)** Claim and conflict touch two user rows; concurrent exchanges taking them in opposite orders deadlock, which `lock_timeout = 2s` does not prevent (deadlock detection fires first) and which surfaces as an unretried 500. State "lock by ascending user id" and extend the existing source-level assertion to cover it. Separately, `decideUnderLock`'s phone UPDATE (`whatsapp.ts:186-188`) must be preceded by a holder SELECT: a unique violation there aborts the whole transaction, and "retry once" as written is a create-path rule that cannot be applied inside an aborted transaction. + +**S6 — Supersede can wedge a live BSUID into a permanent 500. (1)(7)** The superseded user keeps its `whatsapp` row with no number, so if that BSUID presents the same `wa_id` again it takes the conflict branch: 500 per message plus an alert, repairable only by a manual unlink. Open question 9 makes this reachable without a recycled number — the spec explicitly accepts that a Meta format or enrolment change (the parent form `US.ENT.`) yields a new BSUID for the same person. Two BSUIDs alternating for one user supersede once, then 500 forever. Name the case beside unlink in the runbook, and consider deleting the superseded user's `whatsapp` row in the same transaction so the dormant BSUID starts clean. + +**S7 — `sha256(number)` is not a privacy control. (3)** The preimage space is ~10¹⁰; the key is reversible in seconds and sits in `auth.rate_limit` in the same database as the numbers. `email-limiter.ts` already HMACs session tokens under `ctx.context.secret`, so `createHmac('sha256', secret)` costs nothing here. (The owner's "raw, no HMAC" decision concerned `account.account_id`, which must be looked up; a rate-limit key has no such constraint.) + +**S8 — Two statements that are no longer true, in documents other people will trust. (3)(6)** (a) "`ROUTER_RULES` stay all `false`; there is no per-IP rule" is false in substance once the plugin is enabled: the plugin ships its own rule for `/phone-number/*` (10/60, `plugins/phone-number/index.mjs`), and `/sign-in/phone-number` falls under better-auth's built-in `startsWith("/sign-in")` special rule (3/10s, `rate-limiter/index.mjs:307-311`), both keyed by `x-real-ip`. They are harmless (no legacy-layer path matches) and in fact protective — which is why they should be documented and pinned, or a later `ROUTER_RULES` entry set to `false` will silently delete a brute-force gate. (b) The runbook's pre-apply note covers the ACCESS EXCLUSIVE lock and duplicate check for `auth.account` only; `0001` now also builds a unique index on `auth.users`, the table every sign-in reads, and needs its own duplicate check in the same quiet window. + +**S9 — "Applied nowhere" is inherited, not verified. (6)** The review doc records the merge precondition as "`0001` applied to staging before the merge"; the owner asserts it hasn't run. If anyone has applied the old `0001`, regenerating in place diverges silently — the new file re-creates `auth.apikey`, so the apply fails, and the journal no longer describes the database. Make the check a step of the phase (ask the operator, or read staging for the `phone_number` column) and name the fallback (`0002` carrying the two columns) so that decision isn't made under deploy pressure. Otherwise the in-place regeneration is sound: both columns are additive, nullable-or-defaulted, and invisible to 0000-era code; the only hard failure is new code on a 0000 database, which the boot check turns into a loud refusal — as intended. + +**S10 — Make "never stored unverified" an invariant rather than a pinned enumeration. (7)** `CHECK (phone_number IS NULL OR phone_number_verified)` in the regenerated `0001` holds for every path in play (exchange create/update, claim, supersede, the plugin's `verify` which sets both, and the plugin's own `user.update.before` hook that clears the flag with the number — `index.mjs:25-31`) and cannot be bypassed by a path written after the pinning test. The migration is being regenerated anyway. + +### Nits + +**N1** The phase's `whatsapp.ts` list names `verified = true` for update, claim and supersede but not for `resolveOrCreateWhatsappUser`'s ordinary create, which today writes `phoneNumber: phone` with no flag (`whatsapp.ts:92-99`) and would default to false. The criterion covers it; the file list should too. +**N2** The five phone paths are hardcoded, where the apikey surface is derived from `.endpoints` with count assertions precisely so a new route cannot slip past the perimeter. `phoneNumber()` has six endpoints, one deliberately path-less (`consumePhoneNumberOTP`) — the same 6/5 shape the apikey phase asserted. Derive it the same way. +**N3** Exposure is asserted server-side only. I confirmed apps/api is clean: the legacy layer projects sign-in to `{userId, sessionToken}` through its zod schema (`apps/api/lib/auth-service/legacy.ts:114-123`), and `/api/account/me` is an explicit projection (`routes/account.ts:174-176`). But `returned: true` means any client that persists or reports a session now holds the number, which no listed test covers — scope the claim or add the check. +**N4** Add a `role IS NULL` holder to the matrix. The cutover copy sets `'user'` explicitly (`scripts/cutover/02-import-auth.sql:111`) and the admin plugin defaults it, so it is rare, but NULL lands in the refuse branch and should be stated rather than inferred. +**N5** A key holder can cause a notice email to any account holding a given number, and learn from a 500 that a number belongs to an admin or service account. Both sit inside the stated trust in the key holder — worth one line in the threat model rather than silence. + +I did not run, edit or touch anything in the worktree; all of the above is from reading. + +**REQUEST CHANGES** (B1 OTP rows survive the stub and `verify` mints a session; B2 `request-password-reset`/`reset-password` bypass the stub and can mint a credential row; B3 numbers are not normalised, so uniqueness and the claim decision are per-string; B4 supersede can strip an admin's number and sessions) \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-codex.md new file mode 100644 index 00000000..1856b6b9 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-s5-codex.md @@ -0,0 +1,25 @@ +I reviewed the documents and the actual spec-329 worktree, including better-auth 1.7.7’s installed phone-plugin source. + +1. **Blocking — The stub does not disable the five routes before side effects.** The installed plugin shows that `/sign-in/phone-number` can create a session without invoking `sendOTP`; reset-request uses the separate optional `sendPasswordResetOTP` and otherwise succeeds; reset-password can change or create credentials; verify can update a user and create a session. Mapping the eventual response to a common 4xx is therefore unsafe. While stubbed, a pre-handler guard must spend the per-number budget and then return the fixed response before any plugin handler executes. Tests must use correct passwords and valid seeded OTPs, assert identical complete responses, and prove zero session, verification, credential, password, and user mutations. This relies on installed plugin behavior not yet recorded as probed. + +2. **Blocking — Supersession does not actually detach the old identity.** Clearing the old number and sessions while retaining its `whatsapp` account row means the old BSUID can immediately exchange again—especially with `phone_number` omitted—and receive a fresh token for the supposedly orphaned account and its threads. A leaked key holder retaining the old BSUID therefore defeats supersession. Supersede must remove or permanently invalidate the old WhatsApp mapping in the same transaction; the criterion that the old user’s other rows remain untouched must exclude that mapping. + +3. **Blocking — Holder classification is too broad and underspecified.** “No non-WhatsApp method” is not sufficient evidence of a valid exchange-created holder: zero accounts, malformed address/`registered_via`, multiple/corrupt rows, or a credential row with a null password must not enter supersede or claim. Claim must also explicitly refuse a holder already linked to another WhatsApp BSUID rather than relying on the existing `(user_id, provider_id)` unique violation. Define and test exact mutually exclusive states: claimable, canonical WhatsApp-only, protected, and corrupt/conflicting; anything outside the first two must fail without writes. + +4. **Blocking — Normalization is not connected to plugin storage and lookup.** The plan shares normalization between exchange and rate-limit hashing, but better-auth 1.7.7 uses the request string verbatim for user lookup, verification identifiers, updates, and uniqueness. Unless a validator or pre-handler rewrite enforces one canonical representation, formatting variants can bypass logical uniqueness, miss claims, and potentially evade per-number budgets. Specify one canonical form for both Meta `wa_id` and plugin requests, enforce it before all reads/writes, and test equivalent variants and invalid forms. This also relies on installed plugin behavior not yet listed as probed. + +5. **Should-fix — Phone password reset bypasses the project password policy.** The plugin only enforces Better Auth’s length bounds; it does not call this project’s `checkPasswordStrength`, and it can create a credential row for an account that previously had none. That is harmless only while the routes are pre-handler-stubbed. Before a real sender is implemented, the design must add the same password-strength policy, retain session revocation, and test credential creation, existing-password replacement, and weak-password refusal. This is another installed-source behavior requiring a probe. + +6. **Should-fix — The exchange intentionally exposes a protected-number oracle.** A fresh BSUID plus an absent/claimable/supersedable number returns success, while an admin, service, non-user, or conflicting holder returns 500. Thus a leaked service key can distinguish protected-account phone numbers, despite OTP responses being uniform. The amendment currently requires this difference, so either explicitly accept it in the threat model and bound/alert it, or redesign the refusal response; it should not be described as enumeration-resistant without this qualification. + +7. **Should-fix — Plain SHA-256 is a reversible dictionary identifier for phone numbers.** Phone numbers have a small search space, so a stable SHA-256 key or exposed prefix can be brute-forced and correlated. Use a domain-separated HMAC with the existing Better Auth secret rather than adding an environment variable, and test that raw numbers never occur in keys, logs, alerts, or Sentry. Also test email sign-in and session responses for the intended self-only exposure, not only phone sign-in. + +8. **Should-fix — The plan and existing tests still contradict the amendment.** The traceability table still says the number is “never returned”; the spec constraint still says “returned by nothing”; existing `api-key.test.ts` and `options.test.ts` pin `returned: false`; and the “Unchanged” criterion does not permit those tests to change. These cannot coexist with the plugin’s `returned: true` behavior. Update all stale criteria and explicitly replace both camelCase and snake_case sign-up tests, including no-write assertions. + +9. **Should-fix — High-risk concurrency evidence can pass by not running.** The real-Postgres races are described as probes/review deliverables, but “all suites green” does not prove they ran. Make the race command fail closed in the gate or require a checked artifact tied to the current revision. Add negative controls for the model/journal and boot checks, and assert retry count, final account mappings, revoked sessions, and both callers’ outcomes—not merely “one phone holder.” + +10. **Should-fix — Post-commit effects and unlink locking need precise ordering.** Emit the link/supersede alert and generic notice exactly once after the identity transaction commits, even if later budgeting or token issuance fails; the notice must contain neither number nor BSUID. For unlink, acquire the user lock by clearing/updating first, then remove the WhatsApp row and sessions in the same transaction, or add an explicit `FOR NO KEY UPDATE`; deleting the mapping before acquiring the user lock leaves an avoidable exchange race. + +The regenerated `0001` approach is otherwise compatible with a 0000-era service: the additions are additive, the boolean has a default, and nullable unique phone values do not disturb old inserts. Keeping `AUTH_DEPLOYED_THROUGH = null`, journal parity, and a boot check for the generated unique constraint is appropriate given that nothing has been applied. + +**REQUEST CHANGES (pre-handler stub, true supersession, exact holder states, canonical phone handling, password-reset policy, oracle documentation, HMAC keys, stale criteria/tests, enforced Postgres races, post-commit/unlink ordering).** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-security-T1-T20.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-security-T1-T20.md new file mode 100644 index 00000000..5be79959 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-security-T1-T20.md @@ -0,0 +1,43 @@ +From security (2026-10-29 23:06): the external round on §5 (owner amendment 1) + phase 8 (codex and claude, both REQUEST CHANGES; claude read the installed phone-number plugin source, dist/plugins/phone-number/{routes,index,schema}.mjs, and dispatch). The full texts are 329-plan-iter2-input-s5-codex.md and 329-plan-iter2-input-s5-claude.md beside this file. + +Recorded verbatim as the reason owner amendment 1 was withdrawn (the owner replaced it with amendment 2, "drop phone number entirely, whatsapp id is enough"). Only T12 carries into amendment 2's phase 8; the rest are moot. + +BLOCKING + +T1 The stub does NOT stop the five routes; it opens a guessable surface. From the plugin source: /phone-number/send-otp creates the verification row BEFORE calling sendOTP and nothing deletes it when the sender throws, so every refused send commits a live 6-digit OTP (allowedAttempts 3 per row, a new row per send); /phone-number/verify is open, consumes it and on success sets verified and CREATES A SESSION with a cookie for whoever holds the number, on a claimed email or social account; /sign-in/phone-number creates a session without ever calling sendOTP; /phone-number/request-password-reset uses the separate sendPasswordResetOTP option (unset in the phase), still creates a reset OTP and returns 200 with no stub call; /phone-number/reset-password on a correct OTP CREATES A CREDENTIAL ROW for a user that has none and sets the password, which also turns a WhatsApp-only holder into a claimable one and revokes its sessions. Required: implement the owner's 'enable the plugin, don't block it' as: the plugin enabled, its paths NOT in disabledPaths, and a BEFORE hook on exactly those five paths that spends the per-number budget and returns the one fixed response (status, headers and body, in one constant) before any plugin handler runs, so nothing is written and nothing is guessable until a real sender exists. Pin: sendPasswordResetOTP unset AND each of the five handlers cannot execute (zero verification, session, credential, user rows after a request with a correct password and a seeded valid OTP). Set allowedAttempts 1 as defence in depth. The mapping cannot be an after hook: when a handler throws, dispatch fixes the status from the error and an after hook replaces only the body, so 'byte-identical' would pass with different status lines. Probe step 1 now covers: the row-before-sender order; verify's writes; the reset routes' behaviour; and that a before hook short-circuits all five. + +T2 Supersede must detach the old identity. Clearing the number and sessions but keeping the old user's whatsapp row means the old BSUID exchanges again (with phone_number omitted) and gets a fresh token for the orphaned account and its threads; a leaked key holding the old BSUID defeats the supersede. Delete the old user's whatsapp account row in the same transaction. Consequence, welcome: the old BSUID starts clean next time (a fresh user), so my residual E (permanent conflict after a Meta reset) disappears and the supersede wedge case with it; rewrite 5.5 accordingly, and the criterion 'old user's rows otherwise untouched' excludes that row. + +T3 Exact holder states, role and service first. role = 'user' AND no service row is a precondition of BOTH claim and supersede, evaluated before anything else: as written, a WhatsApp-only user later promoted to admin matches supersede's predicate, and a leaked key would clear an admin's number and revoke an admin's sessions. Define four mutually exclusive states: claimable (role user, no service row, no whatsapp row, at least one credential or social row), canonical WhatsApp-only (role user, exactly one whatsapp row, its noreply address, registered_via whatsapp, no other rows), protected (admin or any non-user role, role NULL, or a service row), corrupt-or-conflicting (anything else: zero account rows, a credential row with a null password, a whatsapp row already present on a claimable holder = already-linked, malformed address or registered_via, multiple whatsapp rows). Only the first two write; the other two refuse with nothing written, 500 + alert. The matrix names the WhatsApp-only admin, the WhatsApp-only service holder and role NULL explicitly. + +T4 One canonical phone form, enforced before every read and write. The plugin stores and looks up ctx.body.phoneNumber verbatim and phoneNumberValidator is unset; wa_id carries no plus. '15551234567' and '+15551234567' would coexist under UNIQUE and every §5.4 branch would miss the holder. Define the canonical form (E.164 digits with no plus, which is wa_id's shape), canonicalise in the before hook by rewriting ctx.body.phoneNumber (signUpGuard shows the return { context: { body } } seam), set phoneNumberValidator to refuse non-canonical input, canonicalise wa_id identically in the exchange, and key the budgets on the canonical string. Tests: both representations of one number against each branch, and invalid forms refused. + +SHOULD-FIX + +T5 Ban before link. resolveOrCreateWhatsappUser runs before decideUnderLock, so a claim inserts the whatsapp row on a banned holder and then answers 403: a write kept on a refused request. Read banned on the holder under its own lock before linking; a banned holder -> 403, no link. State that a claim spends the holder's per-user budget. + +T6 Lock order and the phone UPDATE. Claim and conflict touch two user rows; concurrent exchanges taking them in opposite orders deadlock, which lock_timeout does not prevent (deadlock detection fires first) and which surfaces as an unretried 500. Lock by ascending user id; extend the source-level assertion. In decideUnderLock, precede the phone UPDATE with a holder SELECT: a unique violation there aborts the transaction and the create-path 'retry once' cannot run inside an aborted transaction. + +T7 The new alerts need the eligibility posture: a counts-only Sentry event PLUS a server log line with the user ids (the linked account; the superseded and new users), never the number or the BSUID; otherwise the runbook's find-by-log-line step cannot find anyone. Emit alert and notice exactly once after the identity transaction commits, even if later budgeting or token issuance fails. + +T8 Unlink ordering: take the user lock first (the UPDATE clearing the columns, or an explicit FOR NO KEY UPDATE), then delete the whatsapp row and the sessions, in one transaction; deleting the row first leaves an exchange race. + +T9 Rate-limit keys: HMAC with the better-auth secret (email-limiter.ts already HMACs session tokens under ctx.context.secret), not plain sha256: a phone number's preimage space is about ten billion, reversible in seconds, and the key sits in the same database as the numbers. Test that raw numbers never occur in keys, logs, alerts or Sentry. + +T10 The known-versus-unknown test cannot pass by not running: assert with a SELECT that the seeded holder exists before comparing, assert the route is reachable (not a blocked path's 404), and compare status line, headers and body. + +T11 Document and pin the plugin's own rate rules: phoneNumber() ships a 10-per-60s rule for /phone-number/* and better-auth's built-in /sign-in rule (3 per 10 s) covers /sign-in/phone-number, both keyed by x-real-ip. They are allowed (no legacy-layer path matches) and protective; say so beside ROUTER_RULES and pin them so a later ROUTER_RULES entry set to false cannot silently delete a brute-force gate. The runbook's pre-apply section gains the duplicate check and quiet-window note for the new unique on auth.users (the table every sign-in reads), beside the one for auth.account. + +T12 'Applied nowhere' becomes a phase step, not an inherited belief: the owner confirms staging, or the step reads staging for the phone_number column; name the fallback (0002 carrying the two columns and the CHECK) so that decision is not made under deploy pressure. + +T13 Add CHECK (phone_number IS NULL OR phone_number_verified) to the regenerated 0001: the invariant then holds for every path including ones written after the pinning test (the plugin's own user.update.before hook clears the flag with the number, so it is compatible). + +T14 Stale statements: the traceability table ('never returned'), the spec constraint ('returned by nothing'), the existing api-key.test.ts and options.test.ts pins of returned:false, and the Unchanged criterion that does not permit those tests to change. Update all of them; replace both the camelCase and snake_case sign-up refusal tests with no-write assertions. + +T15 Race evidence must not pass by not running: the real-Postgres race command either fails closed in the gate or produces a checked artifact tied to the revision; add negative controls for the getAuthTables model test, the journal test and the boot check; the race tests assert retry count, final mappings, revoked sessions and both callers' outcomes. + +T16 Threat model lines: the exchange is a deliberate oracle toward the key holder (a fresh BSUID with a protected holder's number answers 500 where others answer 200), and a key holder can cause a notice email to any account holding a given number; both sit inside the stated trust in the key holder and are bounded by the budgets and alerts; write them down rather than leave them implied. Password strength on reset-by-phone (the plugin enforces only length and can create a credential row) goes into the real-sender re-review constraint (D). + +NITS + +T17 The ordinary create path in resolveOrCreateWhatsappUser must write verified = true too (today it writes the number with no flag). T18 Derive the five phone paths from phoneNumber().endpoints with count assertions (6 endpoints, 5 routed; consumePhoneNumberOTP is path-less), as the apikey surface is. T19 Scope the exposure criterion to the server side (the number is returned to the user's own client, which may persist it; apps/api's legacy layer projects sign-in to userId and sessionToken, confirmed). T20 Spec 321/#327 note through me: a number can now be claimed or superseded. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md new file mode 100644 index 00000000..87cd37f1 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md @@ -0,0 +1,25 @@ +# Plan 329, security architect plan review (codex + claude on 124d115): response + +All items accepted. + +- P1: sign after commit, with the same exp; explicit pool max and connectionTimeoutMillis in createDb; a pool-starvation probe (max=2, 10 concurrent) that also reproduces the hang with signing inside. +- P2: the session token comes from `generateRandomString(32, 'a-z', 'A-Z', '0-9')` (the public `better-auth/crypto` export, same generator family as createSession's generateId); a probe compares it with a createSession token's shape; a test pins length and alphabet. No new dependency, no deep import. +- P3: fail-closed gates before each probe's first consumer (key probes → phase 4; JWT, field, token, lock and pool → phase 5; logger and outage mapping → phase 6). +- P4: AUTH_DEPLOYED_THROUGH bump to 0001 with main's agreement at the rebase, raised in the journal-numbering message; idx [0, 1] and table-list edits. +- P5: per-key minute and day alerts read the key row (requestCount; refillAmount − remaining since lastRefillAt), not auth.rate_limit (pruned hourly); short-window alerts are in-process; a clock-advance test. +- P6: criterion → owner → test table added, with the missing tests codex listed. +- P7: wording aligned (counts, windows and hash prefixes); the missing-constraint refusal logs {name, code}. +- P8: flush with a timeout; on failure, a banner + key log + non-zero exit, with the write kept; spec amended. +- P9: spec "Unchanged" criterion amended to name log-output tests. +- P10: the route checks Content-Type itself; tests for text/plain and a missing content type. +- P11: an apps/api resolve-user test pins the system-only refusal; recorded in arch.md. +- P12: one green command per phase (root turbo test, typecheck and lint, cold at least once); a runnable demo harness for the phase-4 CLI. +- Nits: session updatedAt named; ipAddress and userAgent written as ''; runbook avoids a bare apply line; users.ts header fix in phase 6; two better-call copies noted in the risks. + +--- + +# Plan 329, iteration 2 (phase 8): response, 2026-10-30 + +Reviews on d3b92b00: codex REQUEST_CHANGES (5 points); gemini COMMENT (skipped, agy exit 3). In parallel: the security architect's own review and external round (T1-T20, `329-plan-iter2-input-security-T1-T20.md`). + +**All five codex points are N/A: the plan they reviewed was withdrawn.** All five concerned owner amendment 1 (the phone-number plugin and claiming by number): Sentry injection for the stub sender, a claimable holder already linked, phone-number normalisation, race assertions, and wiring for the mailer and the phone alert. Before any of it was built, the owner replaced amendment 1 with amendment 2: "drop phone number entirely, whatsapp id is enough". Phase 8 is now "Remove the phone number" (spec §5). There is no plugin, no claim, no number to normalise and no notice. The points were accepted on their merits while amendment 1 stood, and drafted, but those drafts were discarded with it. The security architect's T12 carries into the new phase 8; T1-T11 and T13-T20 are moot. The record is in the review, under "Owner amendment 1: withdrawn". diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-codex.txt new file mode 100644 index 00000000..0473b11a --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-codex.txt @@ -0,0 +1,22 @@ +--- +VERDICT: COMMENT +SUMMARY: Implementation is well-tested and security-conscious; correct the stale PR-body claim about Sentry scrubbing. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- The PR body says only the exchange is scrubbed, but `SERVICE_SENTRY_OPTIONS.beforeSend` now scrubs every apps/auth request after #334. Update the PR description to match the code and review document. +- Migration `0001_api_key` remains a documented pre-merge staging gate; no code change is needed. + +PR_SUMMARY: | + ## Summary + Adds operator-managed service API keys and a WhatsApp BSUID-to-user-JWT exchange to apps/auth. Service ownership, eligibility, session creation, privacy, and migration readiness are enforced defensively. + + ## Key Changes + - Adds the pinned Better Auth API-key plugin, schema migration, boot validation, blocked HTTP surface, and key-management CLI. + - Adds `POST /api/whatsapp/session`, reserved noreply identities, rate limits, alerts, and end-to-end apps/api coverage. + - Accepts but never reads or stores `phone_number`. + + ## Test Plan + - Run package tests, lint, typecheck, and build. + - Apply and verify migration `0001_api_key` on staging before merge. + - Complete the staging WhatsApp smoke test before production. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-security-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-security-claude.md new file mode 100644 index 00000000..8d97e493 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-extra-phase8-security-claude.md @@ -0,0 +1,79 @@ +I'll start by locating the worktree and the actual files on disk.# Review — PR #331, phase 8 ("Remove the phone number", owner amendment 2) + +Scope: `7d1a163d..3ff38416` (= `origin/builder/spir-329`), read from the worktree at `.builders/spir-329`. Note up front: the increment is **one file larger than the diff I was handed** — `codev/resources/arch.md:25` was changed in `47b4dfc0` and does not appear in the pasted diff. I read it; it is correct for the final state (no phone claim). Worth knowing only because the omission was silent. + +--- + +## (1) Completeness of the removal + +**No findings.** The removal is complete in the senses that matter — read, store, log, echo, derive. + +Rationale: the column is gone from `packages/auth/src/schema.ts` (`users` no longer declares `phoneNumber`), the better-auth additional user field is gone from `packages/auth/src/index.ts` (and `packages/auth/tests/config/options.test.ts` now pins the surviving three: `first_name`, `last_name`, `registered_via`), and the phone-update block is gone from `decideUnderLock` in `packages/auth/src/whatsapp.ts`. The route no longer names the field except in prose: `apps/auth/src/routes/whatsapp.ts:9-11` documents accept-and-ignore accurately, and line 20 keeps the number in the never-leak list, which is the correct remaining mention. I independently re-ran the source-scan logic from `apps/auth/tests/no-phone-source.test.ts` as a read-only `node -e` pass over `packages/auth/src` + `apps/auth/src` and got `files: 28 hits: 0`, matching the review doc's reported numbers exactly. Docs are consistent, not partially fixed: `docs/self-hosting.md:243` ("accepted and ignored; no phone number is stored"), `docs/cutover/service-keys-runbook.md:126-127`, `docs/cutover/better-auth-cutover.md:340`, `packages/auth/README.md:15,59`, `codev/resources/arch.md:25`. That is the project's own "fix a documentation defect everywhere" lesson honoured. + +--- + +## (2) The regenerated migration + +**No findings.** It satisfies the constraint exactly. + +Rationale: `git diff 7d1a163d HEAD -- packages/auth/drizzle/` shows the rename `0001_api_key_phone.sql → 0001_api_key.sql` plus **exactly one** deleted line — `ALTER TABLE "auth"."users" ADD COLUMN "phone_number" text;--> statement-breakpoint` — and nothing else in the SQL. `meta/_journal.json` carries `0000_auth_schema` / `0001_api_key` with `when` values unchanged in kind; `meta/0001_snapshot.json` differs only by a fresh `id` and the dropped column. I grepped for anything asserting a `when` or a snapshot `id` and found nothing, so the regeneration cannot have broken a hidden pin. Every tag reference is consistent: the journal, the SQL filename, the fully tag-driven `apps/auth/scripts/cutover/auth-journal.mjs`, `packages/auth/tests/migration/auth-journal-parity.test.ts:95`, `apps/auth/tests/cutover/copy.test.ts:464/472/484`, `apps/auth/tests/cutover/auth-journal.test.ts:53`, `apps/auth/tests/docs/self-hosting-docs.test.ts:81-82`, `apps/auth/tests/docs/cutover-docs.test.ts:465`, the three runbooks and the README. The only surviving `api_key_phone` strings are untracked files, a corrected thread line, and historical quotes in the review's appendix — all correct. The boot assertion is unaffected: `apps/auth/src/boot.ts:31-43` probes only the `auth.apikey` relation (42P01) and `hasAuthUniqueConstraint` on `auth.account`, neither of which mentions a users column. + +--- + +## (3) The accept-and-ignore contract + +**nit — no composite shape in the accept list.** `apps/auth/tests/whatsapp-session.test.ts:338-345` covers seven shapes (digits, leading `+`, JSON number, `null`, empty string, 33 chars, letters and spaces) but no nested object, array or boolean. The contract is nonetheless airtight by construction, not by enumeration: `exchangeBody` at `apps/auth/src/routes/whatsapp.ts:85-90` destructures only `bsuid` and **returns a fresh object literal** `{ bsuid }`, so the spread at line 181 (`{ userId, ...body }`) provably cannot carry any other key into `issueWhatsappToken`, whatever its shape. I settled this by reading rather than by mutating: there is no path from `req.body.phone_number` to state, because the only object that reaches state is one the route built itself. The nit is that a reader has to notice that to trust it; one `['a nested object', () => ({ n: remember(...) })]` row would make the type-independence explicit. + +**nit — the JSON-number shape is outside the never-log needle set.** Line 341 (`['a JSON number', () => Number(...)]`) does not go through `remember()`, unlike the five string shapes. `remember()` (lines 40-49) is what seeds the file-wide log capture at lines 795-803, so that one value is checked against the response body and the auth rows but not against the captured log lines. Low risk — a number cannot reach a log line by a path a string couldn't — but the asymmetry is unintentional-looking. + +**nit — `EXCHANGE_BODY_LIMIT` headroom is argued, not tested.** A worst-case accepted body (256-char `bsuid` + a long `phone_number`) is roughly 330 bytes against a 1024-byte limit, so the limit cannot reject a legitimate WhatsApp request. The 400 list at `apps/auth/tests/whatsapp-session.test.ts:~541-557` tests the limit's upper edge (`pad: 'x'.repeat(limits.EXCHANGE_BODY_LIMIT)`) but nothing pins that a maximal *valid* body still passes. A single 200 case with a 256-char bsuid plus a 64-char phone would convert the arithmetic into a regression guard. + +No finding on the response or log surface: `post()` at lines 117-136 asserts every response against the **raw body text** and against each string value longer than 2 chars, the Sentry scrub test still carries `data: '{"bsuid":"US.1","phone_number":"15551234567"}'` as its needle, and the log capture asserts `logged.length).toBeGreaterThan(0)` so it cannot pass by capturing nothing. The needles survived the increment, as amendment 2 requires. + +--- + +## (4) Test quality + +**should-fix — the `ALTER TABLE … ADD COLUMN` branch of the cutover journal check is now uncovered.** `apps/auth/scripts/cutover/auth-journal.mjs` (`objectChecks`, ~lines 60-64) has a branch that turns an `ADD COLUMN` statement into an `information_schema.columns` check. Its only two assertions were `apps/auth/tests/cutover/auth-journal.test.ts:29` (was `toContain("column_name = 'phone_number'")`) and `apps/auth/tests/cutover/copy.test.ts:487` (same), and phase 8 inverted both to `not.toContain('phone_number')`. Since no journal migration adds a column any more, that branch is now operator-script code with zero coverage — and it is precisely the code the next column migration's cutover will depend on, in a hand-applied-migration regime where a wrong check is a silent "verified". This is the project's own lesson verbatim: *a verification pattern is code, and untested code is not evidence*. The fix is one assertion, no fixture: `objectChecks('ALTER TABLE "auth"."users" ADD COLUMN "x" text;', '0002_add')` should yield the `column_name = 'x'` check. Note the file keeps its unknown-statement negative (`DROP COLUMN` → throws) and its "a check that only knew 0000 would pass there" control, so the surrounding discipline is good — this one branch just fell off the edge. + +**should-fix — `packages/auth/tests/config/api-key.test.ts:310-337` can pass vacuously.** The rewritten all-tables scan loops tables asserting `expect(JSON.stringify(rows), table_name).not.toContain(number)`, guarded only by `expect(tables.length).toBeGreaterThan(0)`. That proves the *table list* is non-empty, not that the dump contains anything: a query that returned `[]` per table (wrong schema filter, a quoting slip, a renamed column) would pass every assertion while proving nothing. Contrast the two scans that amendment 2 actually names, both of which carry a positive control: `apps/auth/tests/whatsapp-session.test.ts:351` (`expect(rows).toContain(bsuid)` — the scan sees this exchange's own writes) and `apps/api/tests/contract/whatsapp-exchange.test.ts:135` (`expect(authText).toContain(bsuid)`). One line — `expect(dump).toContain(email)` — brings this one up to the same standard. Secondary, same hunk: the rewrite dropped the former `expect(signUp.status).toBe(400)` pin in favour of "any non-200", so better-auth's unknown-field behaviour at sign-up is now pinned in neither direction. That is a defensible choice (plan line 461 says so deliberately), but the behaviour actually observed should be written down somewhere, or the next upgrade silently changes meaning. + +**No finding on the model test — the deviation is pinned, not waived.** `packages/auth/tests/schema/better-auth-model.test.ts` runs `getAuthTables(createAuth(drizzleFor(client)).options)` against the journal-built pglite, maps types through `COMPATIBLE`, checks `required → NOT NULL`, asserts `Object.keys(models.apikey!.fields)).toHaveLength(21)`, asserts no phone field, and has a negative control that drops `apikey.key` and retypes `name` to integer. I checked the deviation question the hard way, by reading the installed plugin rather than trusting the test's framing: in `@better-auth/api-key@1.7.7` the apikey model has **exactly 21 fields** and `referenceId: { type: "string", required: true, input: false, index: true }` with **no `references`**. So `DEVIATIONS = { 'apikey.referenceId': 'uuid' }` is live — it is not shadowed by the `def.references ? ['uuid']` branch — and the test fails if that column ever becomes `text`. All 21 required/optional flags line up with the drizzle table's nullability, so the test is genuinely green rather than silently green. + +**nit — the model test's negative control is partial and one-directional.** It exercises the missing-column and wrong-type branches but not required-but-nullable or missing-table, and it cannot see columns the database has that better-auth doesn't model. The second gap is covered elsewhere (`auth-schema.test.ts:83` plus the journal-parity test), so this is a completeness note, not a hole. + +--- + +## (5) Anything weakened from the approved design + +**No findings.** I re-read the parts phase 8 touches or sits next to, and nothing regressed. + +Rationale: `packages/auth/src/whatsapp.ts:140-231` still sets `lock_timeout` and `statement_timeout` via `set_config(..., true)` before `.for('no key update')`, still re-reads the user inside the lock (`user-gone`), still applies all four eligibility checks (`accounts`, `address`, `registered_via`, `role !== USER_ROLE` — exactly `user`, not "not admin"), still deletes sessions and returns `{kind:'banned'}` on a ban, still reuses a session only against `exp + SESSION_MARGIN`, and `issueWhatsappToken` still signs **after** `db.transaction(...)` commits. The route is unchanged in every respect that matters: one 401 body for every key refusal (`refuse` → `unauthorized`, `apps/auth/src/routes/whatsapp.ts:92,116-119`), the per-user budget consumed on the pool *outside* the locking transaction with the comment explaining why (lines 172-179), the 404 catch-all so OPTIONS and sub-paths never fall through to `corsFor` (lines 219-221), and `fail()` logging `redactError(err)` — `{name, code}` only, never the raw driver object (line 122). The removal of the phone column also moots the old rationale behind `packages/auth/tests/migration/auth-journal-parity.test.ts`'s deployed-state test ("the phone_number additional field makes that insert fail"), but the test now does a generic sign-up/sign-in and remains meaningful; nothing is weaker. + +**nit — cosmetic JSDoc rewrap.** `packages/auth/src/whatsapp.ts:149-150` reads awkwardly after the phone sentence was removed ("…the ban and the session. Nothing here signs or spends a / * rate-limit budget."). Purely editorial. + +--- + +## (6) Review-doc and PR-body accuracy + +**nit — review doc line 5 overstates the phase-8 verdict.** It says "Eight phases, each approved by Codex", but the recorded phase-8 verdict at lines 236-238 is `### Phase 8 (Round 1)` / `#### Codex (COMMENT)`. A COMMENT is not an approval; the summary line and the record disagree. Worth a word change because this document is the audit trail for a hand-applied-migration change. + +**nit — review doc line 98 is stale.** The superseded-deviation bullet still ends "the body tests swap the 400 case for a 200 case (red without the route change)", but phase 8 **deleted** those six phone 400 cases outright. The bullet is already marked superseded, so this is low-stakes, but it describes tests that no longer exist. + +**nit — the plan was not amended the way the spec was.** `codev/specs/329-….md` folds amendment 2 in properly with strikethroughs (§5 ~233-257, criteria 248-251/310-314/486-487, risk table 523-524). `codev/plans/329-….md` adds a correct phase 8 at line 435+, but lines 10, 75, 90, 96, 100, 128, 264, 269-274, 287-301 and 353 still describe the phone column and the phone update as live, unmarked. Same "partial fix makes several docs disagree" hazard the increment otherwise avoided. + +**Positive note on the doc's honesty.** Line ~487 records plainly that T12's two read-only staging queries were **not run** and that the claim rests on the owner's written record. That is the right way to carry an unverified premise, and the residual risk is bounded: if migration 0001 had in fact been applied, the apply would fail loudly on `CREATE TABLE "auth"."apikey"`, and the `0002` fallback is recorded. Line 457 (migration = one-line diff) matches what I measured. The PR body is accurate for the final state: `0001_api_key`, "no phone column", the phase-8 testing paragraph, and the counts (@ansari/auth 254 + 1 skipped, ansari-auth 472, ansari-api 1139 + 3 skipped, typecheck/lint 13/13). + +--- + +## Library behaviour I relied on that the review doc does not probe + +Three things, stated plainly: + +1. **`getAuthTables` field shape.** The model test's correctness depends on `type`, `required`, `fieldName` and `references` meaning what it assumes. The review doc asserts the resulting schema match but does not probe the model itself. I verified it by **reading** installed `@better-auth/api-key@1.7.7` (`dist/index.mjs`): 21 apikey fields, `referenceId` as string/required/`input:false`/`index:true` with no `references`. That is what makes the single deviation load-bearing rather than decorative. +2. **better-auth core 1.7.7 unknown-field handling at sign-up.** The rewritten `api-key.test.ts:310-337` accommodates either behaviour (reject, or accept-and-drop). Neither the test nor the doc pins which one 1.7.7 does; I did not probe it. That is the basis of my should-fix note about recording the observed behaviour. +3. **I did not run the suites.** This is a read-only review of a live builder worktree, so running vitest would write caches into the tree under review and could collide with the builder. The recorded test counts are taken on trust from the PR body. Where I needed evidence rather than trust, I re-implemented the check read-only outside the tree (the source scan: `files: 28 hits: 0`) or settled it by reading source. I was not asked to edit anything, and I changed nothing. + +--- + +APPROVE WITH CHANGES (1: add a one-line unit assertion covering `objectChecks`'s `ALTER TABLE … ADD COLUMN` branch in `apps/auth/tests/cutover/auth-journal.test.ts`; 2: add a positive control — `expect(dump).toContain(email)` — to the all-tables scan at `packages/auth/tests/config/api-key.test.ts:310-337`, and record which sign-up behaviour better-auth 1.7.7 actually exhibits; doc nits optional) \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-codex.txt new file mode 100644 index 00000000..d9810322 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-codex.txt @@ -0,0 +1,21 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Implementation is strong, but deployment prerequisites remain incomplete and grant-admin does not fully enforce the documented noreply restriction. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Merge is explicitly blocked: `AUTH_DEPLOYED_THROUGH` remains `null`, `RELEASE.md` lacks the `0001_api_key_phone` apply record, and staging migration verification has not occurred. +- `grant-admin --revoke ` bypasses the reserved-domain checks and succeeds, contrary to the spec and documentation. Move the checks before the revoke branch or narrow the documented contract, with a regression test. + +PR_SUMMARY: | + ## Summary + Adds operator-managed service API keys and a secured WhatsApp session exchange to apps/auth. It also reserves the noreply identity domain and verifies exchanged JWTs through apps/api’s existing authentication path. + + ## Key Changes + - Adds API-key schema, migration, operator tooling, boot checks, alerts, and blocked HTTP surfaces. + - Adds the WhatsApp identity/session exchange with delegation, locking, privacy, and rate-limit controls. + - Adds extensive integration, security, documentation, and real-Postgres probe coverage. + + ## Test Plan + - Apply and verify migration `0001` on staging, update the journal deployment marker, then rerun the full suite. + - Add coverage confirming all grant-admin operations refuse noreply identities. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-rebuttals.md new file mode 100644 index 00000000..d8c11c89 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter1-rebuttals.md @@ -0,0 +1,22 @@ +# PR review (review phase), iteration 1: response + +## Codex (REQUEST_CHANGES) 1: merge is blocked (`AUTH_DEPLOYED_THROUGH` null, no `0001` apply record, staging not verified) + +**Not changed now. This is the agreed merge precondition, not a defect, and it is stated in the PR body.** + +Per the security architect's standing order, `AUTH_DEPLOYED_THROUGH` goes to `0001`, with a second `auth-journal-applied: 0001_api_key_phone` line in `RELEASE.md`, at the **rebase after spec 300's production cutover**. develop is frozen for apps/auth and packages/auth until then, and `0000` itself is not yet applied in production. Bumping now would assert an apply that has not happened. That is exactly the false assertion the parity test's design warns against (arch-critical: "the bump is a human ASSERTION"). The order: the duplicate pre-check, then apply `0001` to staging before merge and verify, then apply to production before the develop → main promote. It is in the PR body and in `docs/cutover/service-keys-runbook.md`. The skipped parity test (`@ansari/auth`, 1 skipped) is the loud marker that this is still open. + +## Codex (REQUEST_CHANGES) 2: `grant-admin --revoke ` bypasses the reserved-domain check + +**The docs are narrowed and the behaviour is pinned. The code is not changed, on purpose.** + +- Revoke only lowers a role to `user`. It cannot grant anything, so it opens no path to privilege. +- It is the repair the runbook prescribes for the eligibility reason `admin` (a WhatsApp user found holding `admin`). Refusing it on noreply addresses would leave only hand-written SQL for that repair. +- Parity with the spec: the criterion reads "refuses a noreply address, as it refuses the system domain". The system domain has never been refused on revoke either (spec 300), so the two domains already behave alike. +- The real defect was the wording. `arch.md` (twice), `RELEASE.md` and the script's header said "refuses the noreply domain" without qualification. All of them now say it never **grants** to a system- or noreply-domain address and that `--revoke` is allowed for any account. The runbook's repair step now names `grant-admin.ts --revoke `. +- Regression test (`apps/auth/tests/grant-admin.test.ts`): a noreply user seeded as `admin` is refused a grant (`NoreplyAddressError`), and `--revoke` demotes it to `user`. Negative run: with Codex's suggested change (the noreply check moved above the revoke branch), the test goes red (1 failed, 14 passed). Restored: 15/15. +- Also green: apps/api `cutover-docs` and `self-hosting-docs` (117), and apps/auth typecheck and lint. + +## Gemini (COMMENT) + +Skipped (agy exit 3), as in every earlier round. Non-blocking; nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-codex.txt new file mode 100644 index 00000000..b1b5bcc7 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-codex.txt @@ -0,0 +1,29 @@ +--- +VERDICT: COMMENT +SUMMARY: Implementation is PR-ready; one migration verification query should be tightened. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `docs/cutover/service-keys-runbook.md:148` verifies the constraint by name globally. Bind it to `auth.account`, type `u`, and ideally its ordered columns; otherwise an identically named constraint elsewhere can produce a false success. + +PR_SUMMARY: | + ## Summary + Adds service API keys and a secure WhatsApp-to-user JWT exchange to apps/auth, while keeping apps/api WhatsApp-agnostic. It also introduces reserved noreply identities, operational tooling, migration guards, alerts, and deployment documentation. + + ## Key Changes + - Adds `@better-auth/api-key`, schema migration `0001`, service-key tooling, and blocked HTTP surfaces. + - Adds `POST /api/whatsapp/session` with delegation, locking, throttling, privacy, and outage handling. + - Adds comprehensive contract, security, migration, documentation, and real-Postgres probe coverage. + + ## Test Plan + - Run `npm test -- --exclude='**/e2e/**'`, `npm run build`, typecheck, and lint. + - Before merge, complete the documented `0001` staging apply, verification, journal bump, and required rebase. + +--- + +## ⚠️ WORKING TREE CHANGED during the codex review (#1649). +A consultant reviews the tree; it must never write to it. 1 path changed while this lane was running: + - packages/auth/scripts/probes/probe-db.ts +Check `git status` and `git diff` before trusting this review or shipping the branch. +Note: under cmap the lanes run concurrently in one worktree, so another lane — or you — +may be the writer. This warning names what moved, not who moved it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-context.md new file mode 100644 index 00000000..9e2f0697 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-context.md @@ -0,0 +1,36 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Implementation is strong, but deployment prerequisites remain incomplete and grant-admin does not fully enforce the documented noreply restriction. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# PR review (review phase), iteration 1: response + +## Codex (REQUEST_CHANGES) 1: merge is blocked (`AUTH_DEPLOYED_THROUGH` null, no `0001` apply record, staging not verified) + +**Not changed now. This is the agreed merge precondition, not a defect, and it is stated in the PR body.** + +Per the security architect's standing order, `AUTH_DEPLOYED_THROUGH` goes to `0001`, with a second `auth-journal-applied: 0001_api_key_phone` line in `RELEASE.md`, at the **rebase after spec 300's production cutover**. develop is frozen for apps/auth and packages/auth until then, and `0000` itself is not yet applied in production. Bumping now would assert an apply that has not happened. That is exactly the false assertion the parity test's design warns against (arch-critical: "the bump is a human ASSERTION"). The order: the duplicate pre-check, then apply `0001` to staging before merge and verify, then apply to production before the develop → main promote. It is in the PR body and in `docs/cutover/service-keys-runbook.md`. The skipped parity test (`@ansari/auth`, 1 skipped) is the loud marker that this is still open. + +## Codex (REQUEST_CHANGES) 2: `grant-admin --revoke ` bypasses the reserved-domain check + +**The docs are narrowed and the behaviour is pinned. The code is not changed, on purpose.** + +- Revoke only lowers a role to `user`. It cannot grant anything, so it opens no path to privilege. +- It is the repair the runbook prescribes for the eligibility reason `admin` (a WhatsApp user found holding `admin`). Refusing it on noreply addresses would leave only hand-written SQL for that repair. +- Parity with the spec: the criterion reads "refuses a noreply address, as it refuses the system domain". The system domain has never been refused on revoke either (spec 300), so the two domains already behave alike. +- The real defect was the wording. `arch.md` (twice), `RELEASE.md` and the script's header said "refuses the noreply domain" without qualification. All of them now say it never **grants** to a system- or noreply-domain address and that `--revoke` is allowed for any account. The runbook's repair step now names `grant-admin.ts --revoke `. +- Regression test (`apps/auth/tests/grant-admin.test.ts`): a noreply user seeded as `admin` is refused a grant (`NoreplyAddressError`), and `--revoke` demotes it to `user`. Negative run: with Codex's suggested change (the noreply check moved above the revoke branch), the test goes red (1 failed, 14 passed). Restored: 15/15. +- Also green: apps/api `cutover-docs` and `self-hosting-docs` (117), and apps/auth typecheck and lint. + +## Gemini (COMMENT) + +Skipped (agy exit 3), as in every earlier round. Non-blocking; nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-review-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt new file mode 100644 index 00000000..061768c1 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt @@ -0,0 +1,22 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Strong security-oriented specification, but unresolved contract decisions and concurrency gaps prevent reliable planning. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Resolve the open questions that alter requirements: domain, BSUID storage/pattern, throttle statuses, banned response, and image guard. +- Verification checks only `whatsapp:mint`; it must also bind `referenceId` to the designated WhatsApp service user. Add a cross-service-owner negative test. +- “One session per WhatsApp user” is not guaranteed: replacing a near-expiry session does not delete the old one, and concurrent exchanges can create multiple sessions. Specify serialization/replacement behavior and concurrent-session tests. +- Specify the privacy-safe email/name derivation and collision handling for generated WhatsApp users. +- Define how plugin-originated errors are classified/redacted so database outages remain 500 and Better Auth’s logger cannot emit driver objects or sensitive identifiers. + +--- + +## ⚠️ WORKING TREE CHANGED during the codex review (#1649). +A consultant reviews the tree; it must never write to it. 3 paths changed while this lane was running: + - packages/auth/package.json + - packages/auth/tests/probe-apikey.test.ts + - pnpm-lock.yaml +Check `git status` and `git diff` before trusting this review or shipping the branch. +Note: under cmap the lanes run concurrently in one worktree, so another lane — or you — +may be the writer. This warning names what moved, not who moved it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md new file mode 100644 index 00000000..4a8ca34b --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md @@ -0,0 +1,22 @@ +# Spec 329, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (lane skipped: the `agy` CLI is not installed; no content to address). +Revision: commit c62d7b6. + +## Codex key issues + +1. **Resolve open questions that alter requirements.** Partly accepted. + - Resolved in the spec, now under "Resolved in this revision": throttle statuses (every key refusal is the one 401, per the security architect); banned user (403 `Forbidden`); check order (key, then body); image guard (dropped, per the security architect; the scripts follow `grant-admin.ts`'s posture plus a printed database host); BSUID pattern (fixed as `^[A-Z]{2}\.[0-9]{10,32}$`, with the WhatsApp builder asked to confirm it against real webhooks); `start` not stored. + - Not resolved, deliberately: the domain name, raw vs HMAC BSUID storage, the key's limits and leaderboard timing. These are the owner's decisions by the architect's instruction, answered at the spec-approval gate. Each has a recommendation, and the requirements are written against the recommended default (working domain name, raw storage), so planning is not blocked. Decision 3 was reshaped by a pglite probe: a refilled budget is safe (refused and kept), while a budget without refill deletes the key, so the script refuses that configuration. + +2. **Bind `referenceId` to the WhatsApp service user.** Accepted. Service users are resolved by a `service` account row (never by email). Permissions are fixed per service in code (the script takes no free-form permissions). The exchange checks the verified key's `referenceId` against the WhatsApp service user, so a mis-owned key is refused with 401. A negative test seeds a `whatsapp: mint` key under another service user. + +3. **"One session per WhatsApp user" not guaranteed.** Accepted. Session work for one user is serialized under a lock on the user's row. A near-expiry session is deleted when it is replaced, and the session carries no IP or user agent. The tests drive concurrent session steps through a seam, since pglite serializes connections. + +4. **Privacy-safe email/name derivation and collisions.** Accepted. Address `wa-@` (independent of the BSUID); name `WhatsApp user`. An address collision is a 500, never a retry onto another user. + +5. **Plugin error classification and logger redaction.** Accepted, and confirmed in the plugin source: `verifyApiKey` returns a driver error as `INVALID_API_KEY`, the same code as an unknown key, after logging the raw error object. The route checks that the database answers before returning 401 (outage → 500). Better Auth's logger is configured to reduce error arguments to `{name, code}`. Tests: a production-shaped outage (500 for valid and unknown keys) and log capture through the outage. + +## Working-tree warning in the codex output + +The three changed paths (`packages/auth/package.json`, `pnpm-lock.yaml`, `packages/auth/tests/probe-apikey.test.ts`) were my own throwaway pglite probe of the plugin, run during the review. It was reverted afterwards; the branch carries none of it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md new file mode 100644 index 00000000..914c7f3d --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md @@ -0,0 +1,73 @@ +I'll ground this review in the actual code before flagging anything.## Review: spec 329 — service API keys and the WhatsApp token exchange + +Grounded against the live tree on `develop` (spec 300 merged, `2c84795`). Files I read: `packages/auth/src/index.ts`, `schema.ts`, `reserved.ts`, `users.ts`, `limits.ts`, `apps/auth/src/app.ts`, `middleware/blocked-paths.ts`, `routes/account.ts`, `alerts.ts`, `apps/api/lib/auth/resolve-user.ts`, `system-accounts.ts`. I read only; nothing in the tree was changed. + +Where I lean on library behaviour the spec does not record as probed, I say so inline. + +--- + +### 1. WhatsApp key → non-WhatsApp user; delegation boundary + +**1.1 No property check on the resolved user — blocking.** The whole delegation guarantee rests on one unstated premise: that nothing but the exchange ever writes an `auth.account` row with `provider_id='whatsapp'`. The route looks up `(provider_id='whatsapp', account_id=bsuid)` and mints for whatever `user_id` comes back, with no assertion about that user. If a `whatsapp` row ever lands on a pre-existing user — a future WhatsApp/Meta social provider configured with that `providerId` (better-auth links social accounts to an existing user; that linking behaviour is *not* recorded in your probe, so treat it as unverified), a backfill script, a cutover copy, or an operator mistake — the key mints a token for that user, and if the user is an admin that token opens `/api/account/*` and the admin surface, since `users.role` is the only admin truth. The fix is one query inside the transaction you already open: refuse unless the resolved user's address is under the non-interactive domain, `registered_via='whatsapp'`, `role` is not `admin`, and the user's *only* account row is the `whatsapp` one. Note the criteria currently pin the weaker invariant ("no `sub` of a user without a `whatsapp` account row"); the one worth pinning is "only a user whose sole account row is `whatsapp`". + +**1.2 Missing service user presents as a revoked key — should-fix.** The owner check resolves the WhatsApp service user by its `service` account row on every request. The spec never says what happens when that row is absent (service user deleted, cascade from a user delete, or script not yet run): the `referenceId` comparison simply fails and the caller gets the one 401. That is a misconfiguration wearing the costume of a revoked key — the exact confusion the 500 rule exists to prevent. Make "service user not resolvable" a 500 plus an alert, and assert exactly one row (the new unique index gives you that only after migration `0001` is applied). + +**1.3 Key-creation path and hashing unspecified — should-fix.** The spec says keys are issued by operator scripts but never says *how*: direct row insert (which must reproduce the plugin's hash exactly, including any server-side salt the probe did not record) or in-process `auth.api.createApiKey` (whose session/`referenceId`/`enableMetadata`/`permissions` requirements the probe also did not record). This matters because a key created with the wrong hash or a dropped `permissions` field fails at the exchange with the same byte-identical 401 as a revoked key — undiagnosable by design. Record the mechanism and the probe that proves it. + +### 2. Oracle, enumeration, timing + +**2.1 Membership-and-ban oracle under key compromise, with no signal — should-fix.** Any key holder can ask "has this BSUID talked to Ansari, and is that user banned" and get 200 / 403 / new-user. BSUID space is unenumerable, so this is a targeted-confirmation leak, not a harvest — but for a service that answers religious questions, confirming a specific person's use is a serious privacy outcome. The structural problem is that the only proposed detection (Open Question 13, an alert on user creations) is blind to exactly this: probing an *existing* BSUID writes no row. Make the mandatory signal volume-per-key on the exchange (requests, not creations), together with alerts on `USAGE_EXCEEDED` and `RATE_LIMITED`, which are otherwise invisible on both sides since everything collapses to 401. `apps/auth/src/alerts.ts` already has the Sentry pattern, so this is cheap. + +**2.2 Body never echoed, and logging surface wider than `console` — should-fix.** The 400 body is constant and key-first ordering is right, so the public oracle story is clean. But the "no BSUID in any log" criterion only captures `console.*` from the exchange. Sentry breadcrumbs/HTTP integrations and any request-logging middleware are not covered, and a request body is the classic way a BSUID or a disguised phone number escapes. Add: no Sentry event from the exchange carries the request body, and no response echoes the input. + +**2.3 Timing — nit.** New-BSUID (lock + two inserts) vs existing (one select) vs 401 are distinguishable by latency, but only to someone who already knows what they sent. Say so in the spec so the plan does not invent constant-time padding. + +### 3. Concurrency and consistency + +**3.1 Session creation and JWT signing mechanism unspecified — blocking.** `auth.session.token` is `notNull().unique()` (`packages/auth/src/schema.ts:70`) and the bearer plugin accepts an *unsigned* bearer, deliberately (`packages/auth/src/index.ts:344-346`). So every session this route creates carries a 30-day full-account credential — one that can reach `DELETE /api/account`. The spec never says who generates that token, with what entropy, whether the session is written by hand or via `internalAdapter.createSession` (the pattern `apps/auth/src/routes/account.ts:192` already uses), how the JWT is then signed from it, or that the session token must never be logged or returned. For a High-tier auth spec this is the one gap I would not let through to the plan: it invents a new long-lived credential without specifying its generation or handling. Name the mechanism, and add the session token to the never-log list. + +**3.2 The lock cannot be tested on pglite, and the seam test is vacuous — should-fix.** The criteria admit pglite serializes connections, then assert "concurrent requests for an existing user leave exactly one session" via "test seams". A seam in your own code proves call ordering, not mutual exclusion: delete the `FOR UPDATE` and the seam test still passes. By your own lessons ("test transactional/atomic behavior against a real DB, not mocks"; "prefer failures that are loud over checks that are quiet"), these two cases need a real Postgres, or the criterion must state what the seam does and does not prove plus a source-level assertion that the session step runs inside the locking transaction. + +**3.3 Signing must happen under the lock, and the claim needs softening — should-fix.** "A token is never signed from a session that dies before the token does" is not achievable: a concurrent ban (step 5), a `DELETE /api/account`, or a near-expiry replacement can kill the session after signing. Specify that signing happens inside the same transaction/lock, restate the invariant as "the session outlives the token at signing time", and add the missing cross-spec contract item: #327 must re-exchange once on a 401 from apps/api. + +**3.4 User deleted mid-exchange — should-fix.** Open Question 12 makes delete-then-recreate normal. An in-flight exchange that resolved a user id can then insert a session against a cascaded-away user → FK violation → 500. Your spec-300 lesson is literally "guard a first-use insert against a concurrent delete" (and `apps/api/lib/auth/resolve-user.ts:135-160` shows the pattern). Specify the behaviour and add the scenario. + +**3.5 The new unique index is the deploy-blocking step — nit.** `unique(provider_id, account_id)` is global over `auth.account`, which today holds only `credential` rows (`schema.ts:105-110`), so duplicates are unlikely — but "unlikely" belongs in a pre-apply check in the runbook, not in a reviewer's head. Also note the constraint build takes an ACCESS EXCLUSIVE lock on a table every sign-in reads. + +### 4. Availability and failure modes + +**4.1 The pre-401 database check misses the most likely outage — blocking.** If `auth.apikey` is absent or shaped wrong (migration `0001` not applied, or a later bump renaming a column), the plugin catches the relation error and returns `INVALID_API_KEY`; a generic `select 1` liveness probe succeeds; the route answers 401. That is the #165 class — code shipped, migration not run — reproduced inside the new credential path, and the risk table claims it is mitigated when it is not. The check before a 401 must touch the `apikey` relation itself, a boot-time assertion should fail loudly if it is missing (precedent: the boot assertion noted in `packages/auth/src/users.ts`), and the criteria need "relation absent → 500, never 401". + +**4.2 Budget exhaustion is a service-wide outage with no containment — should-fix.** The exchange is the only route outside the better-auth handler with no per-user budget; `limits.ts` and `routes/account.ts` establish the opposite convention (`ACCOUNT_ENDPOINT`, 60/min per user, via the existing `consumeWindow`). One looping conversation or a token-cache bug on one BSUID can therefore consume the entire daily budget and take every WhatsApp user down, reported as 401. Add a per-BSUID budget inside the key's outer bound. Also size the budget against instances × restarts, since spec 321's 15-minute cache is per process. + +**4.3 Key deletion destroys the only trail — should-fix.** Expiry prunes the row on verify, and revoke deletes by id, so the forensic record of a key that mattered is script stdout. Prefer revoke = `enabled=false` (your probe shows disabled keys are refused and kept), record issuance/rotation/revocation somewhere durable (runbook entry or a Sentry event at creation), and either add an expiry-approaching alert or drop `expiresAt` on the production key and drive rotation from the runbook. + +**4.4 Internal `/token` path interactions — nit.** If signing goes through the jwt plugin's endpoint in-process, apps/auth's perimeter hooks and `TOKEN_EXCHANGE_PER_SESSION` apply, its 429 has no mapping in your contract table, and a `rate_limit` row accrues per WhatsApp session. Specify the mapping or the bypass. + +### 5. Untestable criteria and missing scenarios + +**5.1 "Blocked in every case and prefix variant, as the admin paths do" is false as written — should-fix.** In `apps/auth/src/middleware/blocked-paths.ts`, only `/api/auth/admin` gets prefix treatment (lines 17, 55-58); every other `BLOCKED_AUTH_PATHS` entry is matched against the exact canonical path via `BLOCKED_EXACT`. Five hardcoded `/api-key/*` strings therefore get exact matching only, and the suffix variants in Test Scenario 10 would fall through. Worse, a hardcoded list goes stale: derive the paths from `apiKey().endpoints` exactly as `ADMIN_PATHS` derives from `admin().endpoints` (`packages/auth/src/index.ts:140-148`), add an `/api/auth/api-key` prefix rule, and assert the derived list is length 5 today — so a plugin bump that adds an endpoint fails loudly instead of quietly publishing it. + +**5.2 The logger criterion can be satisfied by destroying it — should-fix.** `logger: { disabled: true }` passes every log-capture assertion. Add criteria that a non-error better-auth log still appears with its level and message, that an error argument reduces to exactly `{name, code}`, and that the test feeds a known driver-error object (and a near-miss that must survive). + +**5.3 "`start` not stored" rests on an unrecorded option — should-fix.** Nothing in the probe table records a config that suppresses `start`, so as written this criterion may be unsatisfiable without the script nulling the column after create. State the mechanism, and make the test stronger than "`start` is null": assert no column of the row contains any substring of the raw key. + +**5.4 Missing scenarios — should-fix.** (a) `auth.apikey` absent → 500 (4.1); (b) user deleted mid-exchange (3.4); (c) the exchange rejects a session token and a JWT presented as the bearer — i.e. no auth fallback — and is not reachable under `/api/auth/*`; (d) CORS: `corsFor(CORS_ORIGIN)` is global (`apps/auth/src/app.ts:62`), so the exchange inherits CORS; it should return no allow-origin for any origin, making browser use structurally impossible; (e) concurrent verifies with the *same* key (the probe tested sequential accounting only). + +**5.5 Staging smoke is someone else's gate — nit.** It depends on #327's amendment landing; name who signs it off so it cannot block or silently pass this PR. + +### 6. Standing invariants + +No contradiction found, and two claims I checked out as accurate rather than assumed. The exchange sits outside the handler path exactly as the account router does (`app.ts:62-67`), so `ROUTER_RULES` all-false is genuinely preserved. And the Current State claim that a second reserved domain "passes apps/api's first use unchanged" is correct for a non-obvious reason: apps/api keeps its *own* copy of `isSystemAddress` (`apps/api/lib/auth/system-accounts.ts:12-31`) rather than importing `@ansari/auth`'s. One implementation trap follows from that (should-fix): extend `isReservedAddress` only, and do **not** widen the exported `isSystemAddress` / `SYSTEM_EMAIL_DOMAIN` — those are the system-domain invariant's name and are asserted by existing tests. Two additions to the never-log rule: the session token (3.1) and the raw key, with coverage over Sentry, not just `console`. + +### 7. Scope + +**7.1 Missing: a documentation criterion — should-fix.** The design leans on the runbook for the limit numbers, rotation, expiry policy and the migration-before-code order, and `docs/self-hosting.md` is listed only as "to be updated". At risk tier High, with a new operator-held credential, that belongs in the success criteria with its contents named. + +**7.2 Limit numbers leave their single home — nit.** `packages/auth/src/limits.ts` declares itself "the only place [limit numbers] are written down" and tests import from it. The key's numbers would live only in a DB row and the runbook. Record them there as documented constants the create script validates against, while keeping the no-defaults rule. + +**7.3 Otherwise the scope is tight — nit.** The non-interactive domain, the blocked surface and the redacted logger are all load-bearing; deferring the leaderboard (OQ4) is right. Two guardrails for the plan: do not let #314's verify route creep in, and land the global logger redaction as its own commit, since it changes every better-auth log line in apps/auth. + +--- + +**Verdict: REQUEST CHANGES** — (1) assert properties of the resolved user before minting (sole `whatsapp` account row, non-interactive domain, `registered_via='whatsapp'`, not `admin`); (2) specify session creation, session-token generation and JWT signing, and add the session token to the never-log list; (3) make the pre-401 database check touch the `auth.apikey` relation, with a boot assertion and a "relation absent → 500" criterion. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md new file mode 100644 index 00000000..f483cb15 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md @@ -0,0 +1,51 @@ +## 1. Delegation boundary + +- **Blocking — Existing account rows can cross the boundary.** Looking up only `(provider_id='whatsapp', account_id=bsuid)` proves that the user has a WhatsApp account, not that they are exclusively a WhatsApp user. The schema permits the same user to also have `credential`, `service`, or another provider account, and permits their `users.role` to become `admin`. The exchange must atomically require `registered_via='whatsapp'`, the non-interactive domain, a non-admin role, and exactly one account row whose provider is `whatsapp`; otherwise refuse. This eligibility check must occur under the same user lock as session issuance. The current `grant-admin.ts` also refuses only the system domain, so it must refuse the new domain. + +## 2. Oracles, enumeration, and timing + +- **Should-fix — The route exposes BSUID state through timing and mutation.** A valid key gets substantially different work for an existing BSUID versus a missing one—lookup/reuse versus a transaction, inserts, conflict handling, and session creation. A key holder can therefore test whether a BSUID was previously registered, while guesses also create durable users. The intentional 403 separately reveals ban status. If this is accepted because the WhatsApp service is the delegation authority, state that threat assumption explicitly; otherwise add mitigation and monitoring. + +- **Should-fix — Reserved-domain anti-enumeration relies on unprobed library behavior.** “Sign-in exactly as unknown” and reset responses being identical do not establish timing equivalence between an existing passwordless user and an unknown address. The specification records no Better Auth probe for these paths. Test latency/query behavior or explicitly accept that only response-shape equivalence is guaranteed. + +## 3. Concurrency and consistency + +- **Blocking — Ban and token issuance race.** The ban check precedes the user-row lock. `banUserByEmail` updates the user and deletes sessions transactionally, but an exchange can read “not banned,” let the ban delete sessions, then acquire the lock and create a fresh session for the now-banned user. Acquire the user lock first, re-read eligibility and ban state under it, and perform session deletion/creation in that same transaction. + +- **Blocking — “Session outlives JWT” is not precisely guaranteed.** Testing whether a session expires more than 15 minutes from an earlier `now` can still yield a JWT whose actual `exp` is later than the session expiry. Session acceptance must compare against the exact issuance timestamp/expected JWT expiry, preferably with a safety margin, and the transaction must commit before the token is returned. + +- **Should-fix — PGlite seams do not prove PostgreSQL locking.** They can prove conflict-recovery logic but not that `FOR UPDATE`, concurrent account insertion, role changes, and session replacement serialize as claimed. Add a real PostgreSQL concurrency test or narrow the success criterion to what PGlite actually proves. + +## 4. Availability and failure modes + +- **Blocking — The post-refusal health check cannot reliably distinguish outage from refusal.** The plugin discards the original driver error. A failed verification followed by a recovered health check becomes 401, while a genuine unknown key followed by a new outage becomes 500. The sustained-outage test misses both transitions. Preserving the verification operation’s database failure—or relaxing the claimed distinction—is required. + +- **Blocking — Unknown-key traffic is unbudgeted database amplification.** Every well-shaped random key performs plugin database work and then another health query, with no applicable key budget and, correctly, no per-IP rule. This public route therefore offers an unauthenticated database DoS path. Add a non-IP global/circuit-breaker design, cache bounded health information, or otherwise avoid the second query without weakening outage semantics. + +- **Should-fix — Budgets are per key, not per service.** Rotation doubles the service’s effective ceiling, and additional live keys multiply it further. Decide whether limits are intended per credential or per service and test the aggregate behavior. The actual production limits also need resolution before approval rather than remaining “about twice peak.” + +- **Should-fix — Request parsing needs a size and ordering design.** Express JSON middleware normally parses before the handler if mounted conventionally, contradicting “key first,” and no body limit is specified. Define route-local authenticated parsing with a strict byte limit so invalid or oversized bodies cannot consume memory before authorization. + +## 5. Success criteria + +- **Blocking — Cleartext `start` storage contradicts the recorded probe.** The probe says the plugin stores the prefix plus three random characters, while the criteria require `start` not to be stored. The specification names no probed plugin option or implementation step that changes this default. Specify, probe, and test the exact configuration. + +- **Blocking — JWT issuance from a hand-created session is unspecified and unprobed.** The specification does not identify a supported Better Auth API that accepts the created session and emits the normal JWT, including how the session token is generated and supplied. Tests can detect a mismatch, but the proposed implementation path is not established by the recorded probes. + +- **Should-fix — The logging criterion is broader than its test.** Capturing `console.*` cannot prove that no Sentry event, platform access log, Better Auth logger sink, or other alert contains an Authorization header, BSUID, or driver object. Scope the assertion to controlled application sinks and separately configure/test Sentry request-header redaction. The custom Better Auth logger contract itself should also be probed. + +- **Should-fix — Missing security scenarios.** Add tests for a WhatsApp account attached to an email, service, or admin user; promotion after initial creation; the ban/issuance race; transient database failure/recovery; invalid-key flooding; multiple-key aggregate budgets; and a database URL containing credentials to prove “print host” never prints userinfo. + +## 6. Standing invariants + +- **Blocking — Admin isolation is incomplete.** Admin truth remains correctly located in `users.role`, but that makes the missing role check especially consequential: a WhatsApp-linked user promoted through the existing operator script would receive an ordinary JWT and then be recognized as an admin by the established HTTP check. Refusing the new domain in `grant-admin.ts` and rechecking `users.role` during every exchange are required. + +- **Should-fix — Secret-safe host output needs a precise definition.** “Print the database host” must mean parsed hostname and optional port only—not the raw URL, username, password, query string, or database URL parse error. Apart from this and the admin-boundary issue, the two-database, public-origin, system-domain, per-IP, and role-source invariants are preserved. + +## 7. Scope + +- **Blocking — Key delivery and storage are missing and contradict “no secret leaves its moment.”** The WhatsApp service must receive and persist the raw key, so it cannot exist only in the creating process’s output. Specify the secure operator-to-Railway handoff, consumer secret storage, rotation procedure, and observability redaction. Printing once is not itself a complete secret-lifecycle design. + +- **Should-fix — Operational documentation is promised but not required.** `docs/self-hosting.md` and the runbook are mentioned, yet absent from the success criteria. Make key issuance, secure handoff, rotation overlap, revocation, expiry/pruning, budget sizing, and migration/deploy order required deliverables. The generic service-key primitive is otherwise justified; migrating the leaderboard remains correctly out of scope. + +**VERDICT: REQUEST CHANGES (close account/role delegation, ban and expiry races, outage classification and invalid-key DoS, cleartext `start`, JWT issuance feasibility, aggregate budgets, and secret handoff/storage).** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md new file mode 100644 index 00000000..d535ba2f --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md @@ -0,0 +1,56 @@ +I'll verify the spec's claims against the actual codebase before reviewing.I verified the spec's factual claims against the worktree at `.builders/spir-329` and against the installed `better-auth@1.7.7` / `@better-auth/api-key@1.7.7`. Evidence is cited inline. + +--- + +## (A) The seven blocking items + +1. **Eligibility of the resolved user** — **Closed.** All four conditions (sole account row, noreply domain, `registered_via`, not admin) are re-read under the lock, with an alert and an eligibility matrix covering email/service/admin/promoted/altered users. +2. **Ban check before the lock** — **Closed.** The ban check now sits after the under-lock re-read, and `users.isBanned` (honouring `banExpires`) already exists in `packages/auth/src/users.ts`, so the mechanism is real. +3. **Session creation / token handling / signing** — **Not fully closed.** The session half is specified. The signing half is wrong in one verified detail: `auth.api.signJWT` takes `{payload, overrideOptions}` and never calls `definePayload`; `signJWT` in `dist/plugins/jwt/sign.mjs:107-116` sets `exp`/`iss`/`aud`/`sub` but sets `iat` **only if the caller passes it**, whereas `/api/auth/token` goes through `getJwtToken` (`sign.mjs:118-128`), which injects `iat` and applies `definePayload`. So an exchange token built as the spec describes carries **no `iat`** — distinguishable from every other apps/auth JWT, against this spec's own constraint. It does not break first use (`apps/api/lib/auth/verify-jwt.ts:138` requires only `exp, sub, sid`), so this is a precision fix, not a redesign: pass `iat`, and make the criterion assert **claim-set equality**, not just `alg`/`iss`/`aud`/lifetime. Second-order: hand-building `{sub, sid}` at the exchange duplicates `definePayload` (`packages/auth/src/index.ts:355`) with nothing pinning them together — add a test that both paths produce the same claim set. +4. **Pre-401 outage check missed an unapplied migration** — **Closed** (a query on the relation itself, not `select 1`), with the cache window and the recover-between residual stated honestly. The boot assertion added alongside it opens a new gap (B2). +5. **Missing service user looked like a revoked key** — **Closed.** 500 + alert, never 401; also distinguished from "another owner → 401". `verifyApiKey` returns the key row minus the hash (`index.mjs:~2010`), so `referenceId` is available without a second read. +6. **"`start` not stored" had no mechanism** — **Closed.** `startingCharactersConfig.shouldStore` is real in 1.7.7 and gates the assignment (`index.mjs:808`, default `true` at `:2346`). +7. **Blocked-path prefix claim** — **Closed.** Current State now matches `apps/auth/src/middleware/blocked-paths.ts:56`: only `ADMIN_PREFIX` is prefix-matched, everything else exact. The new `/api/auth/api-key` prefix rule is the right shape. The derivation count is wrong, though — see B5. + +--- + +## (B) New gaps the revision introduces + +**B1. The "No CORS" claim has no mechanism and contradicts the wiring the spec itself records — blocking.** +`app.ts:60` does `app.use(corsFor(getEnv().CORS_ORIGIN))` as the first middleware, and `corsFor`'s origin callback (`middleware/cors.ts:27-31`) decides on **origin membership alone, never path**. A request to `/api/whatsapp/session` carrying `Origin: ` will therefore get `Access-Control-Allow-Origin` echoed, and the `cors` package will answer the `OPTIONS` preflight before the route is reached. The spec asserts "the global `corsFor` does not apply to this route" as if it were a fact; two paragraphs earlier it correctly says the middleware is mounted globally ahead of every route. This is the same species as iteration 1's prefix-matching defect: a claim with no mechanism. The fix isn't free either — re-ordering or path-scoping a global middleware in an auth service has blast radius on sign-in's CORS and on `mergeExposedHeaders`, so the spec should name the mechanism (path-scoped mount, or an exchange-local header-stripping middleware) rather than leave the builder to discover it when the criterion fails. + +**B2. The boot assertion conflates "relation missing" with "database unreachable" — blocking.** +`apps/auth/src/index.ts` deliberately does **zero** database work before `listen()` — the comment at lines 15-17 records the owner amendment that removed the boot admin check precisely so "a stale list can never stop sign-in", and `createDb` connects lazily (`db.ts:9-10`). The spec adds "asserts that `auth.apikey` exists and refuses to start if it does not" with no distinction between a deterministic schema fault and a transient connection fault. A database blip during a deploy would then stop the whole service — sign-in, JWKS, account endpoints — not just the exchange, and under a capped restart policy one blip becomes a permanent outage (this is a named critical lesson in `lessons-critical.md`). Make the assertion fail **only** on a relation-missing error code (`42P01`), treat unreachable as "start and let `/health` report 503", and add the converse success criterion — otherwise the fail-loud guard is strictly a new availability risk to the one thing that must not go down. + +**B3. The per-user budget's executor is unspecified, and the refusal path can roll it back — blocking.** +`consumeWindow` (`packages/auth/src/rate-limit-store.ts:31`) is a single upsert on `auth.rate_limit` against whatever `Db` it is handed; the existing precedent (`routes/account.ts:142`) calls it on the pool, **before** the handler and outside any transaction. The spec places it as step 5 *inside* the locking transaction without saying which executor it gets. If it gets the transaction, then returning 429 (and every other refusal: 403, 500, eligibility) rolls the counter back, so the budget never accumulates and never binds — a limit that passes by not running, which is the exact failure mode `lessons-critical.md` warns about. If it gets the pool, it is correct (no FK to `auth.user`, so no lock interaction) but then it is not protected by the lock, which the spec should say out loud. Pin the executor and add a criterion that the counter survives a refused request. + +**B4. The `FOR UPDATE` / `KEY SHARE` hazard is correctly identified but has no containment, only a probe — should-fix (high).** +The analysis is right: a session insert's FK takes `FOR KEY SHARE` on the parent user row, which conflicts with `FOR UPDATE` held by another connection, and because the holder isn't waiting, Postgres sees no cycle and will not break it — the request hangs until a timeout that the spec never specifies. `createDb` uses a default `pg` pool (`max: 10`), shared by every route, so ten hung exchanges stall sign-in and JWKS too. Two concrete improvements: (a) require a `lock_timeout`/`statement_timeout` on the exchange's transaction so the worst case is a 500 rather than pool exhaustion; (b) consider `SELECT … FOR NO KEY UPDATE` instead of `FOR UPDATE` — it does **not** conflict with `FOR KEY SHARE`, so the hazard disappears by construction, while still conflicting with a concurrent `DELETE` (which takes `FOR UPDATE` strength) and with the ban `UPDATE` (`FOR NO KEY UPDATE`). That preserves every race the spec needs serialized and demotes the probe from gate to confirmation. + +**B5. "Derived from `apiKey().endpoints` and asserted to have length 5" is false — should-fix.** +Probed from the installed plugin: `Object.keys(apiKey().endpoints).length === 7`, and two of them (`verifyApiKey`, `deleteAllExpiredApiKeys`) have `path === undefined` because they are built with `createAuthEndpoint.serverOnly` (`index.mjs:864`, `:1951`), which takes no path. `ADMIN_PATHS` maps `endpoint.path` over all values (`packages/auth/src/index.ts:140`), so copying it "exactly" yields seven entries, two `undefined`, which would flow into both `disabledPaths` and `BLOCKED_EXACT` as garbage. The guard fails loudly in the right direction, so it can't ship silently broken — but the spec should state the filter and assert **both** numbers (7 endpoints, 5 routed), since that pair is what actually detects a plugin bump. Happily, this also confirms the separate criterion "`/api-key/verify` has no route." + +**B6. 500 for an ineligible user leaves a permanently bricked BSUID and mis-signals to the consumer — should-fix.** +The choice is defensible for the oracle reason, but it collides with the consumer contract written three sections earlier: "treat 500 as 'apps/auth is failing'". For an ineligible user the condition is permanent and purely local, so the WhatsApp service will retry and page the operator forever for one user while apps/auth is perfectly healthy — and those 500s will sit in the same bucket an operator watches for real outages. Either give the consumer a documented terminal status for this case, or keep 500 and add two things the spec currently lacks: an explicit statement that these 500s are expected to be non-retryable, and a runbook step for what the operator does when the eligibility alert fires (today the alert has no response procedure, and the user stays bricked). + +**B7. The alerts contract contradicts itself on identifiers — should-fix.** +The Alerts criteria are headed "counts only, no identifiers", yet the eligibility alert carries "user id only" and the script events carry key ids. `apps/auth/src/alerts.ts` establishes the opposite posture deliberately — counts, window, and 8-char hash prefixes, "never an email" — and `arch-critical.md` records "counts and hash prefixes, never identifiers" as a system-shape fact. Pick one and say it: a key id is arguably not personal data, a user UUID is pseudonymous and a departure. If the departure is intended, justify it in the spec (an eligibility violation is unactionable without the id, which is a fair argument) and amend the criteria header rather than leaving two rules that disagree. + +**B8. Unprobed behaviour I am relying on the spec to flag — nit, but name them.** +The spec's assumption list covers `createApiKey`, `shouldStore`, `signJWT`, the logger and `createSession`-in-transaction. Two more are relied on silently: that `internalAdapter.createSession` leaves IP and user agent null when given no request context (asserted as a criterion, never listed as a probe), and that a `serverOnly` endpoint invoked through `auth.api` inherits the ambient transaction context at all — `signJWT` and `verifyApiKey` are both endpoints, not plain functions, so probe 4 should cover `signJWT` as well as `createSession`. + +--- + +## (C) Success criteria that are untestable as written + +- **The session margin has no value and no home.** "`expiresAt` ≥ the token's `exp` + the margin" appears three times; the number appears nowhere, and `limits.ts` is declared the single home of limit numbers. Unmeasurable until it is a named constant. +- **"The session write does not wait on that lock."** A non-blocking assertion has no failure mode unless it is expressed as a deadline — as written, the CI symptom is a hung suite, not a red test. State the timeout. +- **"apps/api's source gains no WhatsApp-specific code."** The scan scope is undefined, and the sibling criterion deliberately *adds* `registered_via = 'whatsapp'` to an apps/api contract test, so a naive grep fails. Per `lessons-critical.md`, the pattern needs a negative test against a known-bad line and a known-good near-miss, plus a hit count. +- **Alert thresholds** (per-key volume, 401 volume) are "over a threshold" with no number; the `limits.ts` bounds criteria likewise depend on open owner decision 3. Expected at this gate, but they are not testable until those land. +- **"No column of the row contains any substring (length ≥ 4) of the raw key."** A 68-character key yields ~65 four-character windows tested against a uuid, a base64url hash and other text columns; chance collisions give this roughly a 0.1–0.5% flake rate per run. Reformulate as: the full key absent from every column, `start` null, and the hash equal to `sha256(key)`. +- **The staging smoke sign-off** names an actor and a scope but no artifact or location, so "signed off" has no checkable state. Say where it is recorded. + +By contrast, two criteria that look soft are genuinely sharp and should be kept verbatim: the logger criterion's "cannot be met by disabling the logger" (the plugin really does log the raw error at `index.mjs:~1987`, so the near-miss cases are the right test), and the production-shaped stopped-database case. + +**REQUEST CHANGES** — B1 (CORS claim has no mechanism and contradicts the global `corsFor` mount), B2 (boot assertion must fail only on relation-missing, not on an unreachable database), B3 (pin the budget's executor so a refusal cannot roll the counter back), A3 (`signJWT` sets no `iat`: pass it and assert full claim-set equality with `/api/auth/token`); plus should-fixes B4–B7 and the C items. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md new file mode 100644 index 00000000..264dc43c --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md @@ -0,0 +1,45 @@ +I verified the current auth sources/config; the iteration-2 spec itself is not present on disk, so I reviewed the supplied revision against them. + +## A. Original blocking findings + +1. **Eligibility check — CLOSED.** Eligibility is re-read under the user lock and covers sole WhatsApp account, reserved domain, `registered_via`, and non-admin role. +2. **Ban before lock — CLOSED.** The lock and re-read now precede the ban decision. +3. **Session/token/JWT mechanics — CLOSED at specification level, but unproven.** It names `internalAdapter.createSession`, keeps the session token internal, and uses server-only `signJWT`; transaction binding, token generation, claims, and exact expiry remain planned probes rather than proven behavior. +4. **Unapplied migration misclassified as 401 — CLOSED in intent.** The relation-level check and boot assertion address it, although their cache creates a new contract contradiction below. +5. **Missing service user looked revoked — CLOSED.** It is explicitly 500 plus an alert. +6. **“Start not stored” mechanism — CLOSED.** `startingCharactersConfig.shouldStore: false` is named and has a planned probe. +7. **False blocked-prefix claim — CLOSED.** The revision accurately distinguishes existing exact matching and adds a canonicalized API-key prefix rule. + +## B. New gaps + +### Blocking + +- **Cached relation check contradicts the promised outage behavior.** A cached successful check permits an unknown key to receive 401 during the cache window after the database fails, which the residuals acknowledge, while success criteria require a stopped database to return “500, never 401.” Separately, missing authorization must be answered without touching the database, contradicting “before any 401” the relation is checked. Define precisely which refusals run the check, the TTL, and outage-test timing; remove the absolute “never 401” claim if the residual remains. + +- **The per-user budget does not protect the shared key budget as claimed.** `verifyApiKey` consumes the key’s remaining/window budget before the body is parsed and the user budget is checked. A looping user can therefore continue receiving 429 while still exhausting the service key for everyone. The per-user budget limits successful exchanges, not key consumption; the risk statement and mitigation must be corrected or the ordering/design changed. + +- **The `FOR UPDATE` resolution is still conditional on unprobed Better Auth behavior.** The current `routes/account.ts` only demonstrates obtaining the internal adapter; it does not demonstrate binding its writes to a Drizzle transaction. If session insertion uses another connection, it can wait forever on the parent-row lock while the locking request awaits that insertion. The real-Postgres probe is correct, but the specification needs a viable fallback that preserves serialization against bans, deletion, and concurrent account insertion—not merely “raise an alternative with the architect.” + +- **The exact-expiry sequence is circular as written.** Session reuse is decided against the JWT’s exact `exp`, but signing occurs afterward. The spec must say whether it signs a candidate first, supplies an explicit expiry, or computes and then verifies the exact claim. This depends on currently unprobed `signJWT` behavior. + +### Should-fix + +- **Boot assertion scope and sequencing are underspecified.** Require the assertion to complete before `listen()`, with redacted failure handling. Checking only `auth.apikey` does not prove the accompanying `(provider_id, account_id)` constraint exists; either assert that constraint too or explicitly justify reliance on the migration’s single-transaction application. + +- **New status details need tightening.** The 403 oracle is explicitly accepted and is coherent. For 429, specify that `X-Retry-After` is an integer number of seconds and how it is derived. This matters to the consumer contract. + +- **500 for an ineligible mapped user is secure but operationally awkward.** It fails closed and makes corruption loud, but it classifies a permanent identity-state violation as a transient service failure, encouraging retries that consume key budget and repeat alerts. Either prescribe no automatic retry for this 500 class or choose a stable non-retry status without exposing further detail. + +- **Alert identifier rules conflict.** Eligibility alerts are said to contain a user id, script events contain service/key identifiers and names, while the Alerts criteria say `alerts.ts` carries “counts only, no identifiers.” Split the policies by alert type and make the assertions consistent. + +## C. Untestable or unreliable success criteria + +- “Every canonicalized variant,” “any suffix,” “every Better Auth path,” and “any origin” quantify over infinite inputs. Define finite equivalence classes or property-test generators. +- “No response echoes any part of its input” is literally impossible/undefined; restrict it to complete sensitive values such as the bearer, BSUID, and request body. +- “No column contains any substring of length ≥4” is probabilistic because unrelated hashes, UUIDs, or metadata can coincidentally share four characters. Assert exact hashing, raw-key absence, and `start` being null instead. +- The stopped-database “500, never 401” criterion is incompatible with the accepted cache-window residual. +- A source-level assertion cannot prove runtime transaction/connection identity; only the real-Postgres probe can. +- “Several concurrent messages” needs a defined concurrency count, synchronization point, and expected responses. +- “Valid key with `auth.apikey` absent” must explicitly mean a formerly valid raw key after dropping the relation from an already-running service, because the boot assertion prevents starting in that state. + +**REQUEST CHANGES (relation-check contract, per-user-budget claim, transactional session fallback, exact-expiry ordering).** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md new file mode 100644 index 00000000..13f89356 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md @@ -0,0 +1,24 @@ +# Spec 329: response to the security architect's review rounds 1 and 2, plus owner decisions + +Inputs: 329-specify-iter2-input-{codex,claude}.md (round 1, folded into 45e7d49), 329-specify-iter3-input-{codex,claude}.md (round 2), owner decisions D1–D4. + +## Owner decisions (all adopted) +- D1: no strict BSUID regex; sanity bounds only (non-empty, ≤256 characters, no whitespace or control characters). The delegation-input criteria are restated as a property of the lookup. +- D2: BSUID stored raw. +- D3: optional `phone_number`, stored on `auth.users` as an additionalField with `input: false` and `returned: false`; absent from /me; one column; in the never-log list. +- D4: session row written directly in the lock transaction (`generateId(32)`, createSession's generator) under `FOR NO KEY UPDATE`; `runWithTransaction` + `createSession` is the probe-chosen alternative. + +## Round 2: blocking (all accepted) +- C1 CORS: the exchange router is mounted before `corsFor`; the criterion is a finite Origin list plus a middleware-order test. +- C2 boot: fail only on `42P01` / a missing constraint; an unreachable database starts the service and `/health` reports it; added the converse criterion. +- C3 budget executor: pool, outside the transaction, after resolve and before the lock; the counter survives refused requests. +- C4 iat: explicit `{sub, sid, iat, exp}` to signJWT; claim-set equality with /token; definePayload agreement test; `exp` fixed before the session decision (non-circular). +- C5: corrected the claim that the per-user budget protects the key budget; the risk row was rewritten. +- C6: the check runs on every plugin refusal; a missing bearer is answered without the database; `APIKEY_RELATION_CHECK_TTL` in limits.ts; the criterion reads "after the cache window". + +## Round 2: should-fix (all accepted) +- C7: 7 endpoints, 5 routed, filtered on a defined path. C8: lock_timeout and statement_timeout. C9: 500 is not retried per message; runbook repair for eligibility. +- C10: alerts carry counts and hash prefixes; user ids only in server log lines; key ids allowed in script events. C11: signJWT in probe 4, as a note. +- Testability: `SESSION_MARGIN` constant; deadlines on probes; the apps/api scan gets a scope, a known-bad line, a near miss and a hit count; key-storage criterion = full key absent, `start` null, hash equality; staging sign-off recorded on PR #327 with counts (5 messages in 1 s → 5 answers, 1 user, 1 session); finite variant and origin lists; the "no echo" criterion is restricted to the bearer, BSUID, phone and body. +- Codex extras: the boot check also asserts the account unique constraint; `X-Retry-After` is integer seconds from consumeWindow; "auth.apikey absent" means dropped under a running service. +- BSUID regex items: moot (D1). diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml new file mode 100644 index 00000000..1b8a769d --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -0,0 +1,81 @@ +id: '329' +title: apps-auth-service-api-keys-bet +protocol: spir +phase: review +plan_phases: + - id: phase_1 + title: Plugin, schema and migration 0001, blocked key surface, library probes + status: complete + - id: phase_2 + title: Redacted Better Auth logger + status: complete + - id: phase_3 + title: Reserved noreply domain + status: complete + - id: phase_4 + title: Service users and key operator scripts + status: complete + - id: phase_5 + title: WhatsApp identity and session core + status: complete + - id: phase_6 + title: The exchange route, boot assertion and alerts + status: complete + - id: phase_7 + title: End-to-end contract test, source scan and documentation + status: complete + - id: phase_8 + title: Remove the phone number (owner amendment 2) + status: complete +current_plan_phase: null +gates: + spec-approval: + status: approved + requested_at: '2026-10-28T04:42:34.533Z' + approved_at: '2026-10-28T05:23:01.015Z' + plan-approval: + status: approved + requested_at: '2026-10-29T23:13:28.562Z' + approved_at: '2026-10-29T23:21:44.444Z' + pr: + status: approved + requested_at: '2026-10-29T23:48:25.816Z' + approved_at: '2026-10-30T00:41:11.575Z' + verify-approval: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-28T04:31:56.766Z' +updated_at: '2026-10-30T00:41:11.575Z' +context_refreshes: + - boundary: plan-phase:phase_2 + at: '2026-10-28T06:20:23.009Z' + acknowledged_at: '2026-10-28T06:20:29.672Z' + - boundary: plan-phase:phase_3 + at: '2026-10-28T07:17:36.215Z' + acknowledged_at: '2026-10-28T07:17:42.817Z' + - boundary: plan-phase:phase_4 + at: '2026-10-28T07:36:09.447Z' + acknowledged_at: '2026-10-28T07:37:18.998Z' + - boundary: plan-phase:phase_5 + at: '2026-10-28T08:09:33.292Z' + acknowledged_at: '2026-10-28T08:09:37.188Z' + - boundary: plan-phase:phase_6 + at: '2026-10-28T08:39:44.585Z' + acknowledged_at: '2026-10-28T08:39:50.175Z' + - boundary: plan-phase:phase_7 + at: '2026-10-28T09:11:23.836Z' + acknowledged_at: '2026-10-28T09:14:38.935Z' + - boundary: enter:review + at: '2026-10-28T09:40:21.030Z' + acknowledged_at: '2026-10-28T09:41:33.771Z' + - boundary: plan-phase:phase_8 + at: '2026-10-29T23:35:10.124Z' + acknowledged_at: '2026-10-29T23:35:19.652Z' +pr_history: + - phase: review + pr_number: 331 + branch: builder/spir-329 + created_at: '2026-10-28T09:44:41.643Z' +pr_ready_for_human: false diff --git a/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml new file mode 100644 index 00000000..49fce85c --- /dev/null +++ b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml @@ -0,0 +1,17 @@ +id: bugfix-330 +title: ci-test-root-pnpm-test-is-not- +protocol: bugfix +phase: pr +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-10-28T10:08:20.523Z' + approved_at: '2026-10-29T21:43:55.072Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-28T07:09:35.707Z' +updated_at: '2026-10-29T21:43:55.072Z' +pr_ready_for_human: false diff --git a/codev/projects/bugfix-333-apps-auth-and-apps-api-sentry-/status.yaml b/codev/projects/bugfix-333-apps-auth-and-apps-api-sentry-/status.yaml new file mode 100644 index 00000000..330e75ed --- /dev/null +++ b/codev/projects/bugfix-333-apps-auth-and-apps-api-sentry-/status.yaml @@ -0,0 +1,17 @@ +id: bugfix-333 +title: apps-auth-and-apps-api-sentry- +protocol: bugfix +phase: pr +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-10-29T21:51:15.937Z' + approved_at: '2026-10-29T22:17:08.174Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-28T10:41:44.385Z' +updated_at: '2026-10-29T22:17:08.175Z' +pr_ready_for_human: false diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 4a53652b..0fcd478c 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -18,9 +18,11 @@ Better Auth, in its own service (spec 300). `packages/auth` (`@ansari/auth`) is **Revocation** is deleting sessions at `apps/auth` (sign-out, password reset with `revokeSessionsOnPasswordReset`, ban, deletion). It is bounded by the JWT lifetime: a JWT already minted keeps working until it expires (≤ 15 min). A rotated signing key does not revoke anything either. -**Identity on `apps/api`.** `requireUser` verifies the JWT, then `resolveUser`: an app row is the user (one indexed read, every request in steady state); only with no row is the tombstone (`public.deleted_users`) read, which refuses and retries a pending auth-side delete. **Invariant: a tombstoned id never has an app row.** Every path that deletes an app row writes its tombstone in the same transaction (`DELETE me`; the orphan cleanup), and the first-use insert re-reads the tombstone in its own transaction and rolls back. A new path that removes a row without a tombstone, or writes a tombstone and leaves the row, breaks `resolveUser`'s row-first order; with no row and no tombstone, the profile is fetched from `GET /api/account/me` with the user's own JWT (a dead session is a refusal) and the row inserted (`ON CONFLICT (id) DO NOTHING`, then a tombstone re-read in the same transaction, so a concurrent deletion cannot be undone). An address already held by another app row, or a reserved address, is refused (401, ids-only log, Sentry alert), never a 500. A first-use outage is 503, never 401, so an outage does not log clients out. +**Identity on `apps/api`.** `requireUser` verifies the JWT, then `resolveUser`: an app row is the user (one indexed read, every request in steady state); only with no row is the tombstone (`public.deleted_users`) read, which refuses and retries a pending auth-side delete. **Invariant: a tombstoned id never has an app row.** Every path that deletes an app row writes its tombstone in the same transaction (`DELETE me`; the orphan cleanup), and the first-use insert re-reads the tombstone in its own transaction and rolls back. A new path that removes a row without a tombstone, or writes a tombstone and leaves the row, breaks `resolveUser`'s row-first order; with no row and no tombstone, the profile is fetched from `GET /api/account/me` with the user's own JWT (a dead session is a refusal) and the row inserted (`ON CONFLICT (id) DO NOTHING`, then a tombstone re-read in the same transaction, so a concurrent deletion cannot be undone). An address already held by another app row, or one on the **system** domain (`@system.ansari.chat`), is refused (401, ids-only log, Sentry alert), never a 500. **The first-use refusal covers the system domain only**: apps/auth's other reserved domain, `@noreply.askansari.ai`, is accepted, because every WhatsApp user's profile carries it (`whatsapp-@noreply.askansari.ai`); a tightening of `resolve-user.ts` to every reserved domain would refuse every WhatsApp user, which `tests/auth/resolve-user.test.ts` pins. A first-use outage is 503, never 401, so an outage does not log clients out. -**Admin & system identity.** Admin = `auth.users.role === 'admin'` (and not banned), asked of `apps/auth`'s `GET /api/account/me` with the admin's own JWT on **every** admin request (`requireAdmin`): a demotion, a ban or a revoked session applies at once, an outage fails closed (503). Never an email match, never a JWT claim. Roles are granted by `apps/auth/scripts/grant-admin.ts`, which never creates an account or touches a password, in two steps: `` shows id, email, `created_at`, `email_verified` and role and grants nothing; ` --confirm ` grants exactly that account. It refuses the system domain and warns on a recent or unverified account (no email verification exists, so an address can be squatted). There is no admin list and no boot check (owner amendment H-7). System identities (`ai-skill`, `leaderboard`) exist only as app rows found by `system_key` (`getOrCreateSystemUser`), with no auth row and no credential; `@system.ansari.chat` is reserved. +**Admin & system identity.** Admin = `auth.users.role === 'admin'` (and not banned), asked of `apps/auth`'s `GET /api/account/me` with the admin's own JWT on **every** admin request (`requireAdmin`): a demotion, a ban or a revoked session applies at once, an outage fails closed (503). Never an email match, never a JWT claim. Roles are granted by `apps/auth/scripts/grant-admin.ts`, which never creates an account or touches a password, in two steps: `` shows id, email, `created_at`, `email_verified` and role and grants nothing; ` --confirm ` grants exactly that account. It never grants to a system- or noreply-domain address (`--revoke`, a demotion, is allowed for any account: it is the repair for a WhatsApp user found holding `admin`) and warns on a recent or unverified account (no email verification exists, so an address can be squatted). There is no admin list and no boot check (owner amendment H-7). System identities (`ai-skill`, `leaderboard`) exist only as app rows found by `system_key` (`getOrCreateSystemUser`), with no auth row and no credential; `@system.ansari.chat` is reserved. + +**Service keys and the WhatsApp exchange (spec 329).** A **service** (`whatsapp`; `ansari-mcp` reserved for #314) is one auth user with no credential under `@noreply.askansari.ai`, found by its `{provider_id: 'service', account_id: }` row, never by email. Its keys come from `@better-auth/api-key` (pinned with core), rows in `auth.apikey` holding only the SHA-256 hash, each with its own per-window limit and daily refilled budget, no expiry, and fixed per-service permissions and prefix (`ansari_wa_`, `ansari_mcp_`), so a key for one service is refused at another's route. The plugin's HTTP surface is blocked; `apps/auth/scripts/service-keys.ts` is the only issuer (shown, then confirmed by id; the raw key printed once; a Sentry record of each write, with no key material). `enableSessionForAPIKeys` is off: a key is never a session. `POST /api/whatsapp/session` (mounted before CORS and outside Better Auth's router, so no per-IP rule and no CORS) verifies the key in process (`verifyApiKey` with `whatsapp: mint`), finds or creates the BSUID's user through `account (provider_id = 'whatsapp', account_id = )` (`unique (provider_id, account_id)` makes it one user under concurrency), checks eligibility under a lock on the user row (only that account row, its own `whatsapp-@noreply.askansari.ai` address, `registered_via = 'whatsapp'`, role exactly `user`, an allowlist; otherwise 500, never a token) and the ban (403, sessions deleted), reuses one session per user, and signs the ordinary `{sub, sid}` JWT after commit. So a key can only ever yield tokens for WhatsApp users: never an email user, an admin, a system or a service account. The BSUID lives only in `account.account_id` and reaches no log, response or `apps/api`. No phone number is stored anywhere (owner amendment 2): a `phone_number` the WhatsApp service sends is accepted and ignored. `@noreply.askansari.ai` is refused at sign-up (as a taken address), reset (as an unknown one), a grant by `grant-admin.ts`, and the `user.create.before` hook on every Better Auth path. **apps/api has no WhatsApp code** (a source scan); the exchange JWT passes `requireUser` unchanged. **Alert posture:** the exchange's alerts (per-key volume at one third of the caps, key refusals, 401 volume, ineligible users, an unresolvable service user) carry key ids, counts, thresholds and windows only in Sentry (a key id is a plugin-made UUID that names a credential, not a person, and matches `list`); user ids appear only in server log lines, and the service's Sentry keeps no breadcrumbs, so a logged id never rides on an event. `apps/auth` refuses to boot without `auth.apikey` or the constraint (migration `0001`). Operator procedure: `docs/cutover/service-keys-runbook.md`. **The legacy surface.** `/v2/users/*` and the two reset routes stay on `apps/api` for the released mobile app, as anonymous pass-through clients of `apps/auth`'s public endpoints (no credential, no forwarded IP), with frozen response shapes (a SHA-pinned contract suite). `register` and `login` return the JWT as `access_token` and the session token as `refresh_token`; `refresh_token` returns a new JWT for the same session. `DELETE /v2/users/me` deletes the app row and writes the tombstone in one transaction, then calls `DELETE /api/account` with the user's JWT (refused before any call unless the token's `sub` is that user). A user who calls `DELETE /api/account` directly leaves the app row and no tombstone: an owner-accepted residual whose operator response is in the runbook ("Orphan app rows"). @@ -62,6 +64,8 @@ The repo is a pnpm workspace of `apps/*` and `packages/*`, driven by Turborepo. **The frontend app (`apps/frontend`, since #262).** The former SDK 54 prototype (outside the workspace until #262) is now the workspace member `ansari-frontend`, on Expo SDK 57 / RN 0.86 / React 19.2, with a vitest suite that CI runs in the `frontend` job (job name frozen). Its store identity is the **legacy app's** (`legacy/frontend-app/app.json`): name "Ansari", slug `ansari-chat`, `chat.ansari.app`, EAS project `e2f465a7-…`, **scheme `ansarichat`**. That scheme is coupled to `packages/auth`'s Better Auth `trustedOrigins` (`'ansarichat://'`); change both together, or native sign-in fails origin validation later with nothing failing at build time. `auth.http.test.ts` pins the pair: it reads `apps/frontend/app.json` and asserts `${expo.scheme}://` is trusted, and it rejects the old `askansari://`. `packages/auth/turbo.json` declares that manifest as a `test` input (`$TURBO_ROOT$/apps/frontend/app.json`), because a file outside the package is not hashed by default and a warm cache would replay the guard instead of running it. The app's whole env surface is `EXPO_PUBLIC_API_URL` (default api-staging), the only ARG in `Dockerfile.web`. Uniwind is installed and wired (metro `withUniwindConfig`, `src/global.css`, `gen:types`) but **unused**: the UI is StyleSheet-only, and `app/_layout.tsx` deliberately does not import `global.css` because Tailwind's preflight reset would restyle the web. `vendor/api-client-react/` keeps only `custom-fetch.ts` (the fetch runtime) and `generated/api.schemas.ts` (types); retiring both is #264. +**The WhatsApp service (`legacy/whatsapp`, spec 321).** A Python (uv, FastAPI) service outside the pnpm/turbo graph, with its own CI job (`whatsapp`) and its own Dockerfile built from the repo root (`-f legacy/whatsapp/Dockerfile .`). It identifies a user by BSUID only (`from_user_id`; it never reads or forwards the phone number), asks apps/auth for a JWT (`POST /api/whatsapp/session`, header `Authorization: Bearer `, body `{bsuid}`, per issue #329: the route does not exist yet, so sign-in is mocked in practice; the token is cached per BSUID for 14 minutes). Sign-in runs in the background task, after Meta has its 200, so a slow apps/auth cannot cause a redelivery. It refuses to start without `META_ANSARI_APP_SECRET`, or with any `MOCK_*` switch outside `DEPLOYMENT_TYPE=local`. It then calls apps/api with that JWT: `GET/POST/DELETE /api/v2/threads` and the SSE `POST /api/v2/threads/{id}/chat`. Each user has one thread (`source: whatsapp`), deleted and recreated after `WHATSAPP_CHAT_RETENTION_HOURS` (3) of silence; a race between two first messages can create two (accepted, swept by hand). It logs one JSON line per entry on stdout with only `request_id` (Railway indexes `@request_id:`), and never logs phone numbers, BSUIDs, message text or the webhook verify token. It reads `PORT` (default 8001) and its Railway settings are recorded in `legacy/whatsapp/railway.toml` (not read by Railway). Its environments share a Meta setup: each environment needs its own Meta app credentials, and the staging `!d ` prefix keeps a developer's local messages from being answered by staging. + **Health endpoint.** `/api/health` returns `service: 'ansari-backend'` — a pinned public contract (spec 3), unrelated to the `apps/api` directory name. It performs a live DB ping with a 2s cap, so a cold process can legitimately 503 on its first request. ## Frontend chat display (apps/frontend) diff --git a/codev/resources/lessons-critical.md b/codev/resources/lessons-critical.md index 2d743109..971ea1c2 100644 --- a/codev/resources/lessons-critical.md +++ b/codev/resources/lessons-critical.md @@ -31,4 +31,4 @@ STARTER: a few universal lessons are seeded; add your project's as you learn the - New Ansari logo — frontend (issue #223) — consult when changing an SVG mark or its viewBox, regenerating icon rasters, adding a test in a new directory, or taking screenshots without the Chrome extension. - Ambient shadow strength (issue #225) + jitter (issue #254) — frontend — consult when tuning a visual intensity constant (size it in ΔL*), turning a knob whose effect a solver may cancel, chasing a smoothness report from a phone, measuring on a real device without a cable, judging a visual change by eye (blind A/B), or changing a video's playback rate, loop encode or arrival. - Scripture in answer prose — frontend (issue #228) — consult when mixing Arabic and English in one text block, classifying text by script, keeping an inline control on its word's line, balancing centred lines, or asserting react-native-web styles in jsdom. -- Better Auth migration (spec 300) — consult when testing a production-only rule, writing psql guard/copy scripts, guarding a first-use insert against a concurrent delete, caching a remote JWKS, reading better-auth internals, bundling native modules, building a service image, or recording an infrastructure inference. +- Better Auth migration (spec 300) + Service API keys / WhatsApp exchange (spec 329) — consult when testing a production-only rule, writing psql guard/copy scripts, guarding a first-use insert against a concurrent delete, caching a remote JWKS, reading better-auth internals or its plugins, locking a parent row (`FOR NO KEY UPDATE`), calling the pool inside a transaction, testing what Sentry sends, bundling native modules, building a service image, or recording an infrastructure inference. diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index a56098e3..d413fa9e 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -220,3 +220,28 @@ See `codev/reviews/300-better-auth-migration.md` for the full context. - **An inference about infrastructure is not a finding.** A Cloudflare egress-IP claim was recorded as a finding from reasoning alone, then had to be withdrawn when a measurement contradicted it. Label it provisional, probe both paths (via the CDN and direct, IPv4 and IPv6), and derive tests from the measured fixture rather than pinning provisional values, which break when the real measurements arrive. - **Expo / React Native traps (frontend, 10b/10c):** expo-router re-syncs URL params (`?token=`) back from navigation state; React Native defines `window` without `location`/`history`, so a web-only change to the app entry can red-box iOS (launch the native app after any app-entry change); react-native-web trims email inputs itself; a rejection escaping `act` drops state updates. - **Next App Router treats `_folder` as private.** A route at `/_diag` lives under `%5Fdiag/`. + +## WhatsApp service port (spec 321) + +See `codev/reviews/321-rfc-whatsapp-support-in-the-mo.md` for the full context. + +- **Never log a webhook verify token, a phone number, a BSUID or message text, and prove it with a test.** A local-only debug branch printed the verify token. The guard is a test that sets a distinctive token, calls the handshake with a right and a wrong value, and asserts neither appears in the captured log; it was shown to fail when the line was put back. +- **A manual run against real services finds configuration errors mocks cannot.** The legacy `.env` mixed three Meta apps (a token for one, an app ID for another, a subscription on a third), and a 15-minute JWT expired mid-session. Check which app a token belongs to (`debug_token`) and which app an ID and secret pair belongs to before debugging code. +- **A test of a flag needs a case for each part of the condition.** The JSON-log decision was tested only with the value it already had, so deleting its CI clause kept the test green. Extract the decision into a function and parametrize it. +- **Do not claim a code path exists that cannot run.** A timeout branch and a note saying timeouts were reported separately were both wrong, because the client wrapped timeouts before the branch could see them. Read what the callee actually raises. +- **Keep a spec's success criteria next to a test or an owner.** The "answers over 4000 characters are split" criterion had neither, and was only noticed while writing the review. +- **Meta test numbers can fail to provision for weeks.** "An error occurred while creating the test account" is widely reported; a business with one live number may be unable to claim one. Ask for a spare number early rather than discovering this at the real-Meta test. +- **Windows, native:** `afx spawn` assumes `/bin/bash`, porch's whole-monorepo test check cannot pass, and `consult` detects a builder worktree by a forward-slash path. Each needed a local workaround; the full list is for an upstream Codev report. + +## Service API keys and the WhatsApp exchange (spec 329) + +See `codev/reviews/329-apps-auth-service-api-keys-bet.md` for the full context. + +- **`@better-auth/api-key` 1.7.7 behaviours, read from probes, not docs.** A key exhausted with no refill is **deleted**, but a refilled budget refuses and keeps it. An expired key is deleted too. `verifyApiKey` logs the raw driver error, then returns `INVALID_API_KEY`, the same code as an unknown key, so an outage looks like a bad key. Check that the relation answers before returning 401, and redact the logger to `{name, code}`. `signJWT` without an explicit `iat` omits the claim, so pass `{sub, sid, iat, exp}` to match `/api/auth/token`. +- **`FOR UPDATE` on a row other tables reference blocks their inserts.** Inserting a session takes `KEY SHARE` on its user row, which conflicts with `FOR UPDATE` but not `FOR NO KEY UPDATE`. Lock a parent row with `FOR NO KEY UPDATE` unless you change its key. pglite has one connection and cannot show this. The real-Postgres probe (step C') did. +- **A pooled call inside an open transaction can starve the pool.** `signJWT` reads `jwks` on a second connection. Called inside the transaction, a pool of 2 with 10 concurrent requests hangs forever. Do the work after the commit, and set `connectionTimeoutMillis` so starvation fails loudly instead of hanging. +- **Sentry sends more than you log.** Its Console integration turns every `console` line into a breadcrumb that rides on the next event, which is how a just-printed raw key leaked. Its request-data integration attaches the body and `Authorization` to any event raised during a request. A test with `defaultIntegrations: false` can show neither. Keep default integrations on and spy only on the transport, and scrub in `beforeSend`. +- **drizzle wraps driver errors.** The SQLSTATE (`55P03`, `23505`) is on `err.cause`, not on the top-level error. node-postgres server errors have `name: 'error'`. +- **A mutant can survive behind a cache.** A relation check cached for a TTL hid a reordering mutant, because an earlier test had warmed the cache. Advance the clock past the TTL in the test that counts queries. +- **An auth plugin's routes are live even when its sender is a placeholder.** better-auth's phone-number plugin with a `sendOTP` that throws still stores a live code before the throw, creates a session on `/phone-number/verify`, signs in by phone and password without ever calling the sender, and sets a password through reset-by-phone, even on an account that had none. Probe a plugin's whole surface before relying on any option to neuter it, and close routes with a before hook (an after hook cannot fix the status line). +- **porch: a rollback to plan resets every plan phase.** `porch rollback plan` re-extracts the plan, and phases already built and approved come back pending. Parent consultation mode emits a `phase-review-*` gate that `porch approve` does not know, and `none` mode loops between `done` and `next`; both dead-end for plan phases (codev of 2026-10). Normal mode replays the recorded review files iteration by iteration, so a re-walk of unchanged phases costs checks only. diff --git a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md new file mode 100644 index 00000000..38b25a33 --- /dev/null +++ b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md @@ -0,0 +1,159 @@ +# Review: WhatsApp support in the monorepo (spec 321) + +## Summary + +The legacy Python WhatsApp service now lives in the monorepo as `legacy/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 79 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). + +## Spec Compliance + +Met means covered by an automated test unless stated. "Live" means checked by hand against a real service, not by the suite. + +- [x] A phone-visible user's message is answered through the new stack. Tested with mocks and a mock transport (phases 2, 4); run live against a real `apps/api` with sample webhooks. +- [x] A phone-hidden user (BSUID only) is answered; no phone number reaches `apps/auth` or `apps/api`. Tested, including a request-body check that no phone or BSUID is sent to `apps/api` (phase 2); live with Meta's sample webhook shape (phase 4). +- [x] The same BSUID is the same user and thread; a new BSUID is a new user (phases 2, 3). Tested with the mock sign-in. +- [x] `apps/api` authenticates WhatsApp requests only via the `apps/auth` JWT; no WhatsApp login logic in `apps/api` (phase 2). No change was made to `apps/api`. +- [x] After more than 3 hours of silence the next message starts a new thread and the old one is deleted (phase 3). Tested, including a real-shaped `...Z` timestamp. "Exactly one thread per user" holds except for a race on two near-simultaneous first messages (see Deviations). +- [ ] Answers longer than 4000 characters are delivered as multiple messages in order. **Not covered by any test.** The splitter (`whatsapp_message_splitter.py`, limit 4000) is the legacy code imported unchanged and used by the conversation manager; no test was written or kept for it in this project. Unverified here. +- [x] Meta always gets a success response immediately, and its signature is verified (phase 1: missing and wrong signature rejected with 403). The "always 200" behaviour is the `ALWAYS_RETURN_OK_TO_META` setting, which tests deliberately turn off. +- [x] Status updates, webhooks for other numbers and unsupported message types do not trigger a chat answer. Status and other-number webhooks were seen being ignored live; the unsupported-type path is covered by the imported legacy test and logic. +- [x] With `WA_MAINTENANCE_MODE` on, users get the notice and no chat call is made, independent of `apps/api`'s `MAINTENANCE_MODE` (phase 4, tested both ways). +- [x] In staging, messages starting with `!d ` are ignored and others processed (phase 4, tested in staging and local). +- [x] No legacy users or threads were migrated. +- [x] Tests use mock Meta and mock backend clients, as in the legacy service. + +Not in the spec's list but part of the owner's Phase 4 requirement: real services. Done by hand on 2026-10-26 (see the plan): real `apps/auth` and `apps/api` with a real JWT; Meta's dashboard sample webhooks through zrok with a valid signature; one real production-number send to a real BSUID, accepted by Meta (HTTP 200, delivery and read statuses followed). Not done: a real phone's webhook arriving at the new service (no Meta test number could be claimed), the Docker image, and the `apps/auth` `/whatsapp` route. + +## Deviations from Plan + +- **Phase 4, `/chat` instead of the raw route.** The client moved to `POST /api/v2/threads/{id}/chat`, which reports failures as an explicit `error` event, so the planned "detect the trailing `Error:` text" work became unnecessary. +- **Phase 4, no per-user lock.** The lock added in phase 3 was removed by the owner. A race on two near-simultaneous first messages can create two threads, and two overlapping answers can interleave in a thread's history. Both are accepted, documented in `_resolve_thread`, and cleaned up by an occasional manual sweep (keep the newest `whatsapp` thread per user). +- **Phase 4, `WA_MAINTENANCE_MODE`.** Renamed from `WHATSAPP_UNDER_MAINTENANCE` and made independent of `apps/api`'s `MAINTENANCE_MODE` (owner decision); the spec was amended. +- **Phase 4, logging.** Logs carry only `request_id` and are one JSON line per entry on stdout, the shape Railway indexes. This replaced the CloudWatch-shaped sink and the user-id tracking, and removed `get_subject`. +- **Phase 4, extra deploy fixes.** The app reads `PORT`; the Dockerfile uses repo-root paths, uv 0.9.13 and `uv sync --frozen`; `legacy/whatsapp/railway.toml` records settings (not read by Railway); a `.python-version` pins 3.13. +- **Phase 4, `MOCK_AUTH_TOKEN`.** A test-only setting so the mocked sign-in can return a real JWT to a real `apps/api`. Not in the plan. +- **Real-Meta test.** The plan assumed a Meta test number; none could be claimed (a widespread Meta-side failure), so Meta's sample webhooks and one production-number send were used instead. See the PR description for the request for a shared staging and test number. + +## Consultation Feedback + +Only Claude was consulted (a decision for this environment, since the other models' tooling is unusable on native Windows). Five reviews were run across four phases. + +### import_service Phase (Round 1) , REQUEST_CHANGES + +#### Claude +- **Concern**: no CI job for `legacy/whatsapp` → **Addressed**: a `whatsapp` job was added to `ci.yml`, with the action pinned to a commit SHA. +- **Concern**: `ruff` configured but not declared → **Addressed**: added to the `dev` extra and locked. +- **Concern**: READMEs reference files that do not exist → **Addressed** in phase 4 (both READMEs rewritten). +- **Concern**: no test for a missing or invalid Meta signature → **Addressed**: two tests added, shown to fail when wrong. +- **Concern**: CWD-relative `logs/` directory created at import; phone numbers logged and sent to Sentry → **Addressed** later (phase 2 redaction; the `logs/` directory and old logger were removed in phase 4). + +### import_service Phase (Round 2) , APPROVE + +#### Claude +- **Concern**: Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile and its second sync is not `--frozen` → **Addressed** in phase 4. +- **Concern**: `conftest.py` comment wrong about `.env` precedence → **Addressed** (comment fixed). +- **Concern**: `test_webhook_message_basic` asserts only HTTP 200 → **N/A**: legacy test kept as imported; not tightened. + +### auth_token_exchange Phase (Round 1) , COMMENT + +#### Claude +- **Concern**: `get_subject()` can raise outside its try block → **Addressed** (fixed and tested); the function was later removed in phase 4. +- **Concern**: token exchange timeout of 15 s exceeds Meta's window → **Addressed**: lowered to 5 s. +- **Concern**: typing indicator never cancelled when sign-in fails → **Addressed**: scheduled only after sign-in succeeds. +- **Concern**: no assertion that no phone reaches `apps/api` → **Addressed**: test added, shown to fail when a phone is injected. +- **Concern**: mid-stream `apps/api` errors relayed unlogged as `Error:` text → **Addressed** in phase 4 by the `/chat` event stream. +- **Concern**: dependency on `from_user_id` and an untested BSUID send → **Addressed** in phase 4 as far as possible: `from_user_id` is present in Meta's sample webhooks, and a real send to a BSUID succeeded once (see Spec Compliance for the limits). +- **Concern**: reviewer did not run `pytest`/`ruff` → **N/A**: run by the builder and CI. + +### thread_lifecycle Phase (Round 1) , APPROVE + +#### Claude +- **Concern**: lock covers resolution only → **Rebutted** by owner decision: the lock was removed entirely and the cases accepted and documented. +- **Concern**: `...Z` timestamps untested → **Addressed** (test added). +- **Concern**: a create response without a thread id leaves the user un-notified → **Addressed** (user is told); Sentry capture added in phase 4. +- **Concern**: thread info/create/delete failures not captured in Sentry → **Addressed** in phase 4, with tests. +- **Concern**: single-replica assumption → **N/A**: no longer applies once the lock was removed. + +### environments_and_docs Phase (Round 1) , COMMENT + +#### Claude +- **Concern**: README and `tests/README.md` contradict the recorded live runs → **Addressed**: rewritten to state what was and was not verified. +- **Concern**: Sentry captures untested → **Addressed**: four tests, each shown to fail when its capture is removed. +- **Concern**: unreachable `TimeoutException` branch → **Addressed**: branch and import removed; timeouts are captured as message-processing failures. +- **Concern**: vacuous JSON-log test → **Addressed**: decision extracted into `should_use_json_logs()` with parametrized tests, shown to fail without the CI clause. +- **Concern**: Python version drift across CI, dev and the image → **Addressed**: `.python-version` pins 3.13; the suite passes on it. +- **Concern**: dead `META_WABA_ID` documentation and a legacy `ngrok` host → **Addressed**: removed. +- **Concern**: maintenance check runs before the `!d ` filter → **N/A**: matches the legacy order and is harmless; recorded in the plan. + +### PR review (PR #327, Round 1) , REQUEST_CHANGES + +#### Claude +- **Concern**: nothing stops `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`, which would put every user in one account and thread → **Addressed**: the settings refuse to load with `MOCK_AUTH_TOKEN` unless `DEPLOYMENT_TYPE=local`; tested, and the tests fail when the guard is disabled. A mocked sign-in without the token hands out fake tokens a real `apps/api` rejects, so it cannot serve real users anywhere; a real deployment needs the `apps/auth` `/whatsapp` route. +- **Concern**: `_resolve_thread` could delete a thread from another client → **Addressed**: only `whatsapp` threads count; tested, fails without the filter. +- **Concern**: Meta's raw error body was logged, against the README → **Addressed**: only the status code is logged; tested for both send paths, fails with the body logging restored. +- **Concern**: dead commented-out debug block → **Addressed**: removed. +- **Concern**: wrong return annotation on `verification_webhook` → **Addressed**. +- **Concern**: `pytest` in runtime dependencies; `requires-python` and ruff `py310` against a 3.13 image; `await request.json()` outside the try block → **N/A** here: legacy packaging and behaviour, listed as follow-ups. +- **Concern**: Docker image never built; CI never run on a runner → **N/A**: stated in the PR; neither can be done from this machine. +See `codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md`. + +### PR review (PR #327, Round 2) , COMMENT + +#### Claude +- **Concern**: all four round-1 findings → **Verified fixed** by the reviewer, each with a non-vacuous test. +- **Concern**: the apps/api client still logged and reported the raw error body → **Addressed**: only the status code; tested, fails with the body restored. +- **Concern**: the staging `!d ` filter crashed on an unsupported message type (list-shaped body), giving a 500 and a Meta retry loop in staging only → **Addressed**: guarded with an `isinstance` check; tested, fails without it. +- **Concern**: one payload interpolation skipped redaction → **Addressed**. +- **Concern**: "Message too old, notified user" while nothing notifies the user → **Addressed**: text and docstring corrected. +See `codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md`. + +## Lessons Learned + +### What Went Well + +- Negative controls. Every test that expects a failure was proven able to fail by briefly breaking the code and restoring it. A first version of the `/chat` error-event test passed with the handling disabled; this practice caught it. +- Reading the real contracts. The `apps/api` thread and chat routes were read in the code rather than assumed, and the client matched them. The Phase 4 real run then confirmed the main path against a live `apps/api`. +- A manual real-service run found problems the mocks could not: an expired 15-minute test JWT (apps/api answered 401 and the failure path told the user), an app ID that belonged to a different Meta app than the secret, and a log line that printed the webhook verify token in local mode. + +### Challenges Encountered + +- Windows. `afx spawn` is unusable (hard-coded `/bin/bash`), porch's whole-monorepo test check cannot pass, and `consult` misdetects the builder worktree. All were worked around locally; the list is kept for an upstream report. +- Meta test numbers. No test number could be claimed, so a real phone's webhook could not reach the new service. The workaround was Meta's sample webhooks plus one production-number send to a developer's own BSUID, which needed a temporary, now commented-out, log line in the legacy production service. +- Claims in docs drifting from facts. The README said nothing had run against live Meta after it had; the review caught it. + +### What Would Be Done Differently + +- Test the 4,000-character splitting. The criterion was never given a test or an owner and slipped through to the end. +- Decide earlier how a real phone's message would reach the service in a non-production environment. The shared-number routing idea (see the PR description) would have solved this at the start. + +### Methodology Improvements + +- Porch's review phase runs a `pr_exists` check before the builder signals completion, but the review file and PR are only written in this phase; the order is confusing for a first-time reader. +- Every phase boundary produces a "refresh your context" task that needs a live builder terminal; it should be skippable when none exists. +- A plan item that no phase owns (here the splitter test, found from the spec's success criteria) should be flagged when the plan is approved, by checking each success criterion against a phase. + +## Architecture Updates + +- Routed: cold, `codev/resources/arch.md` ("Monorepo layout, build & deploy") — `legacy/whatsapp` is a Python service in the repo: identifies users by BSUID only, mints a JWT through `apps/auth` (route and header assumed, sign-in mocked until `apps/auth` has the route), calls `apps/api`'s `/chat`, logs one JSON line per entry on stdout with only `request_id`; deployed by Dockerfile `legacy/whatsapp/Dockerfile` with the repo root as build context. +- No hot-tier change: both hot files are at their 10-fact cap and nothing here changes behaviour for builders working in other apps. Not demoted. + +## Lessons Learned Updates + +- Routed: cold, `codev/resources/lessons-learned.md` (new "WhatsApp service port (spec 321)" section) — do not log a webhook verify token or any user identifier, and prove that with a test that fails when the line is restored; a manual run against real services finds config errors (wrong app ID for a secret, expired tokens) that mocks cannot. +- No hot-tier change: the existing hot lesson "prefer failures that are loud over checks that are quiet" already covers the negative-control practice. + +## Flaky Tests + +No flaky tests encountered in `legacy/whatsapp`. On native Windows, porch's whole-monorepo `npm test` check could not pass, so it was narrowed locally to `legacy/whatsapp` through a git-ignored `.codev/config.json`; CI on Linux runs the stock checks for the other apps. + +## Follow-up Items + +- Create a new WhatsApp phone number shared by staging and test (call to action for Waleed; see the PR description), since a Meta test number cannot be claimed. +- Test the 4,000-character splitting; it has no test. +- Replace the mocked sign-in once `apps/auth` ships the route from issue #329 (`POST /api/whatsapp/session`, `Authorization: Bearer `, body `{bsuid}`, response `{token}`). +- Build and run the Docker image; run the staging smoke test in `legacy/whatsapp/README.md`. +- Raise with the `apps/api` owner: the chat routes save every message with `source: 'web'` even for WhatsApp threads. +- Make the `whatsapp` CI job a required check on `develop` (needs a repo admin; the job has not yet run on a GitHub runner). +- Occasional manual sweep: delete all but the newest `whatsapp` thread per user. +- Remove the dead legacy `ORIGINS` and CORS middleware if no browser calls this service. +- Packaging clean-up: move `pytest` to the `dev` extra, align `requires-python` and ruff `target-version` with the 3.13 image, and move `await request.json()` inside the webhook's try block (a signature-valid non-JSON body currently returns 500). +- A real deployment needs the real `apps/auth` `/whatsapp` route: the mocked sign-in cannot serve real users outside `local` (fake tokens are rejected by `apps/api`, and a shared real token is refused by the settings). diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md new file mode 100644 index 00000000..e731b594 --- /dev/null +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -0,0 +1,994 @@ +# Review: Service API keys and the WhatsApp token exchange (spec 329) + +## Summary + +apps/auth gains service identity through `@better-auth/api-key` (pinned to core 1.7.7): a service is one credential-less auth user, its keys are issued only by `apps/auth/scripts/service-keys.ts`, and the plugin's HTTP surface is blocked. Phase 6 adds `POST /api/whatsapp/session`, which turns a `whatsapp: mint` key and a BSUID into the ordinary EdDSA `{sub, sid}` JWT, only ever for a WhatsApp user. A second reserved domain, `@noreply.askansari.ai`, is refused on every public path. Eight phases: phases 1 to 7 were approved by Codex, and phase 8's recorded verdict is COMMENT, which porch counts as approval (Codex's one point was this document's stale summary, since fixed). The Gemini lane was skipped in every round. Phase 8 implements owner amendment 2 (2026-10-30): no phone number is stored anywhere, and a `phone_number` the WhatsApp service sends is accepted and ignored. Amendment 1 (better-auth's phone-number plugin and claiming accounts by number) was withdrawn before being built; the record is in the appendix. The end-to-end test shows an exchange token passing apps/api's `requireUser` unchanged, and apps/api gains no WhatsApp code. Two items stay open, neither of which this PR can close: applying migration `0001` to staging before the merge (owner decision, 2026-10-28; production gets the whole auth journal at its cutover), and the staging smoke, which #327's builder runs. + +## Spec Compliance + +Owner phase per the plan's criterion table. Where the plan named a test, that test is green; the evidence for the real-Postgres criteria is in the appendix. + +**Service keys and scripts** +- [x] Plugin pinned to exactly 1.7.7; core not bumped (Phase 1) +- [x] `apikey` table in the `auth` schema; migration `0001_api_key` with `unique(provider_id, account_id)` and no phone column (regenerated in place by phase 8; it was applied nowhere), generated and reviewed as SQL; schema, journal-parity and better-auth model tests (Phases 1 and 8). `0001` is applied to staging before the merge; `AUTH_DEPLOYED_THROUGH` stays `null` until main sets it at the production cutover (see Follow-up). +- [x] Boot refuses on `42P01` or a missing constraint, bound to `auth.account` and the exact column order (Phase 6, tightened in review iteration 1) +- [x] Database unreachable at boot → starts, `/health` 503, recovers without a restart (Phase 6; production-shaped run, steps 4 to 5a) +- [x] Blocked list derived from `apiKey().endpoints` (7 endpoints, 5 routed), the full variant list, `/verify` and `/delete-all-expired-api-keys` covered by the prefix; the prefix proven load-bearing (Phase 1) +- [x] `enableSessionForAPIKeys` false: no session or token from a key, as `x-api-key` or as a bearer (Phase 1) +- [x] Key row: the raw key in no column, `start` null, `key` = `defaultKeyHasher(raw)` (Phase 1) +- [x] Create prints the key once; list, rotate and revoke print no key or hash (Phase 4) +- [x] Every subcommand: URL required, `--confirm `, host and port printed before connecting, key id logged; the URL leak test (Phase 4) +- [x] Create refuses a budget without refill, a missing rate limit, out-of-bounds values and any expiry (Phase 4) +- [x] `create-service` writes the user and its `service` row in one transaction and refuses a duplicate name (Phase 4) +- [x] Rotate leaves two live keys; revoke sets `enabled = false`, keeps the row, and the key then gets the 401 (Phase 4, Phase 6) +- [x] Sentry event per write with no key material; a failed flush keeps the write, prints the banner and exits 3 (Phase 4; breadcrumb leak found and closed in review iteration 2) +- [x] Refilled exhaustion refuses and keeps the key, then verifies after the interval (Phase 1) +- [x] `grant-admin.ts` never grants to a noreply address, as for the system domain; `--revoke` stays allowed (the repair for a promoted WhatsApp user), pinned in the PR review (Phase 3, PR review iteration 1) + +**Exchange: key refusals** +- [x] Every listed key state answers the identical 401 and creates no row: no header (zero queries), non-Bearer, empty, a session token, a JWT, unknown, revoked, expired, exhausted, rate-limited, `mcp: complete`, no permissions, mis-owned (Phase 6) + +**Exchange: failures that are not 401** +- [x] `auth.apikey` dropped under a running service → 500 for both keys after `APIKEY_RELATION_CHECK_TTL` (Phase 6) +- [x] Database stopped under a production-mode service → 500 after the TTL; 401 inside it is the stated residual (Phase 6; real Postgres run, 3b to 3d) +- [x] Missing or malformed bearer → 401 with zero counted queries (Phase 6) +- [x] Service user missing → 500 plus an alert (Phase 6) +- [x] User deleted between lookup and lock → 500, no session, no token (Phase 5 library, Phase 6 HTTP) + +**Exchange: identity and delegation** +- [x] New BSUID → one user at `whatsapp-@noreply.askansari.ai`, one `whatsapp` row, one session, a JWT that verifies against JWKS (Phase 5) +- [x] Same BSUID twice → same `sub` and `sid`, one session (Phase 5) +- [x] Session `expiresAt` ≥ `exp` + `SESSION_MARGIN`, else replaced (Phase 5) +- [x] Claim-set equality with `/api/auth/token` (keys, `alg`, `kid`, `iss`, `aud`, lifetime) and agreement with `definePayload` (Phase 5; `kid` added in review iteration 1) +- [x] Eligibility matrix (email user, service user, admin, promoted, address or `registered_via` changed, plus a re-pointed row) → 500 and an alert, never a token (Phase 5, Phase 6) +- [x] An admin's or email user's id or email, or a `google` `account_id`, passed as `bsuid` is an unknown BSUID (Phase 6) +- [x] Ban → 403, sessions deleted, a pre-ban JWT fails `/me`; an expired ban passes (Phase 6) +- [x] Per-user 429 with integer `X-Retry-After`; another user unaffected; the `rate_limit` key holds the user id (Phase 6) +- [x] The counter still counts a request refused with 403 or 500 (Phase 6) +- [x] `lock_timeout` and `statement_timeout` from `limits.ts`; a row lock held from another connection → 500 at 2027 ms (Phase 6, real Postgres) +- [x] Concurrency: a forced unique conflict yields one user; a ban between lookup and lock is seen under the lock; the source-level placement assertion; probe 4 F1 to F4 on real Postgres (Phase 5) + +**Exchange: privacy, logging, transport** +- [x] BSUID only in `account.account_id` (Phase 5) +- [x] **Amendment 2: no phone number stored anywhere.** `phone_number` in seven shapes → 200, and an all-tables scan of `auth` finds none of them; the end-to-end test scans the auth side too; a source check finds no code reading it; a sign-up carrying it leaves it in no row (Phase 8) +- [x] `auth.users` has no `phone_number` column, and the schema matches better-auth's own model of the configured auth, with one documented deviation (`apikey.reference_id` uuid with a foreign key) (Phase 8) +- [x] No raw key, hash, session token, BSUID, phone or driver error in any captured log (Phase 6) +- [x] Logger redacts to `{name, code}`, is not silenced, near misses survive (Phase 2) +- [x] Exchange Sentry events carry no body, `Authorization` or phone, with a negative control (Phase 6) +- [x] No response echoes the bearer, BSUID, phone or body (Phase 6) +- [x] No `Access-Control-*` header for the four Origin cases, POST and OPTIONS; mounted before `corsFor` (Phase 6) +- [x] Over-limit body → 400 after the key; an unkeyed body is never parsed (Phase 6) + +**Alerts** +- [x] Per-key volume, `USAGE_EXCEEDED` / `RATE_LIMITED`, 401 volume, ineligible user (with the server log line) and unresolvable service user; thresholds are named `limits.ts` constants at one third of the caps (Phase 6) + +**Reserved domain** +- [x] Sign-up: noreply and taken answer identically, 409 unpadded and 400 padded (Phase 3; the spec text was amended, see Deviations) +- [x] Near misses sign up normally (Phase 3) +- [x] Reset for a noreply user answers as an unknown address, and the mailer is never called (Phase 3) +- [x] `user.create.before` refuses both reserved domains on every Better Auth path (Phase 3) +- [x] Sign-in with a noreply address answers as an unknown one (Phase 3) +- [x] `isSystemAddress` and `SYSTEM_EMAIL_DOMAIN` unchanged; their tests unedited (Phase 3) + +**End to end** +- [x] Exchange JWT passes `requireUser`: JWKS, first-use `/me`, `public.users` row with `registered_via = 'whatsapp'` (Phase 7) +- [x] Source scan 5: 0 hits in 65 files, a known-bad line caught, near misses outside the glob not caught, a planted file goes red (Phase 7) +- [ ] Staging smoke: not run yet. It is #327's builder's to run against staging with a script-issued key, and to record on PR #327. The spec makes it a pre-production gate, not a merge gate for this PR. + +**Documentation** +- [x] `docs/self-hosting.md`: route, scripts, `limits.ts` bounds with their ranges, migration before code (Phase 7) +- [x] Runbook: issuance, handoff, rotation overlap, revocation, budget sizing, the duplicate pre-check and lock warning for `0001`, alert meanings, scheduled rotation, secret scanning, eligibility repair, key log (Phase 7) +- [x] Secret-scanning patterns tested against real keys and near misses (Phase 4), and the runbook's table equals the code's patterns (Phase 7) +- [x] `arch.md` Authentication & Authorization describes keys, the exchange, the noreply domain and the alert posture (Phase 7) + +**Unchanged** +- [x] `ROUTER_RULES` all `false`, pinned (Phase 6) +- [x] Existing tests pass. Edited only where the spec allows (blocked paths, schema, reserved check, log output), plus one change the spec did not foresee: a 100 s hook deadline in `apps/auth/tests/secrets.test.ts` and `apps/auth/tests/cutover/copy.test.ts`, so they finish under machine load (see Flaky Tests; no assertion changed). +- [x] No new environment variable (four-way derivation, Phase 7); `turbo.json` unchanged. + +## Deviations from Plan + +- **Phase 1:** the probe scripts live in `packages/auth/scripts/probes/`, not `apps/auth/scripts/probes/`, because apps/auth has no direct `pg` / `drizzle-orm` dependency. `probe-db.ts` holds the setup they share. +- **Phase 2:** the two spec 300 setup hooks got a 100 s deadline (a `chore(test)` commit). They kept timing out the plan's green command on the shared machine. The security architect said fix them, don't skip them. +- **Phase 3:** the spec said a padded noreply sign-up gets 409. `signUpGuard` validates first (the frozen legacy order), so every padded address gets 400. The code was left alone and the spec text amended. The property the criterion protects, no oracle, holds. +- **Phase 4 (choices inside the plan):** `create-service` confirms with the service name, since no user exists yet. `update-limits` resets `remaining` to the new budget. `create-service` also sends a Sentry event. The exit codes are 0, 1, 2 and 3. The script has its own Sentry options and prints through `process.stdout.write` (from review iteration 2). The plan's URL leak test was narrowed, because the script's own text contains the word "user". `ansari-mcp` borrows `WHATSAPP_KEY_BOUNDS` until #314. +- **Phase 5:** eligibility requires the exact address `whatsapp-@noreply.askansari.ai`, which is stricter than any address under the domain. Lifetimes moved to `src/lifetimes.ts`. Signing after the commit is the approved plan (plan iteration 2, P1). The spec's §2 sentence that put signing inside the transaction was amended to match. +- **Phase 6:** there is one constant beyond the plan's list, `EXCHANGE_ALERT_WINDOW`. The catalog lookup `hasAuthUniqueConstraint` lives in packages/auth, because apps/auth has no `drizzle-orm`. The window alert is deduplicated by the time since it last fired, because the plugin's window resets after a gap rather than on a fixed clock. +- **Phase 7:** the key log is a table at the end of the runbook; the spec named it and the plan did not place it. +- **Review phase (security architect, superseded by phase 8):** an explicit `"phone_number": null` was accepted as absent (200, a stored number untouched) instead of 400. The contract calls the field optional, and JSON clients commonly send `null` for an absent field. Phase 8 goes further: every shape is accepted and ignored. It deleted the six `phone_number` 400 cases and this `null` case, and replaced them with accept-and-ignore tests over every shape, each followed by an all-tables scan. +- **Review phase (gitleaks):** the probes' invented placeholder secrets are gone. `setProbeEnv` now calls `setAuthEnv` and overrides only `DATABASE_URL`, so the probes print the test issuer and audience (`auth.test.example`, `ansari-api-test`), not the `probe` ones shown in the appendix's earlier runs. The two literals stay in branch history, so `.gitleaks.toml` allowlists them as adjudicated fixtures (the run is under "Secret scan (gitleaks)" below). + +- **Review phase (security architect, PR-gate pass 2):** **service-wide, Sentry keeps no breadcrumbs.** `SERVICE_SENTRY_OPTIONS` now sets `maxBreadcrumbs: 0` and `beforeBreadcrumb: () => null` for all of apps/auth, not only the exchange. Nothing relies on breadcrumbs, and the ineligible-user log line (a user id) otherwise rode on the alert as a console breadcrumb. Per-key alerts carry the key id (`keyId`, as `list` prints it) instead of an 8-character hash prefix: a key id names a credential, not a person. The fire-once alerts lose their count fields (always 1 or the threshold), and the runbook says they mark the first occurrence in the window. `hashPrefix` was removed as unused. + +- **Review phase (security architect, PR-gate pass 1):** eligibility now requires the role to be **exactly `user`**, an allowlist. Before, it refused only `admin`, which missed `user,admin` (which better-auth's admin plugin reads as admin) and unknown roles. The reason is renamed from `admin` to `role` in code, the runbook and the doc test. `update-limits` and `revoke` now confirm with the **key's** id, not the service user's. `--name` and `--client-id` are restricted to an inert charset (1 to 64 characters) with key-shaped values refused. With no `SENTRY_DSN`, the script takes the unrecorded path on purpose, through an explicit `unrecordedRecorder`. + +- **Owner amendments 1 and 2 (2026-10-30):** amendment 1 (the phone-number plugin and claiming accounts by number) was folded into the spec, planned as phase 8 and reviewed, then withdrawn by the owner before any of it was built. Amendment 2 ("drop phone number entirely, whatsapp id is enough") replaced it: phase 8 removed the phone number, reversing owner decision D3. Porch was rolled back from review to plan for this. +- **Phase 8 (choices inside the plan):** the migration was regenerated in place under a new tag, `0001_api_key`, since the old name would mislead (agreed with the security architect, main informed). Step 1 (T12, `0001` applied nowhere) rests on the owner's written record; the two read-only queries were not run, because the apply step never ran. +- **Porch re-walk:** the rollback reset every plan phase. Phases 1 to 7 were re-walked by replaying their recorded reviews, with no code change and no new consult (appendix). + +- **Merge of develop with #334 (2026-10-29):** the exchange's Sentry scrub is now #334's `scrubRequestEvent`, applied to every request. An exchange event keeps its method, path and the `host`, `user-agent` and `content-type` headers, where the exchange-only scrub kept method and path. None of those headers carries a key, BSUID or phone number. The phase 6 notes below describe the exchange-only scrub as it was then. + +### Accepted residuals + +- **The eligibility read of account rows is not serialized against a concurrent insert of a second account row for the same user** (security architect, review phase). An account insert takes `KEY SHARE` on its user row, which `FOR NO KEY UPDATE` does not block, so a second row committed during the exchange's transaction is not seen by that exchange. No Better Auth path can create an account row for a noreply user (the create hook and every public path refuse the domain), so only an operator can write one. The next exchange reads it under the lock and refuses with `ineligible: accounts`, raising the alert, so the window is one exchange and its token (at most 15 minutes, `{sub, sid}` only, carrying no authority beyond that WhatsApp user's own). + +## Consultation Feedback + +In every round the Gemini lane returned `COMMENT` with "lane skipped — agy exited with code 3". It has no content, is non-blocking, and is not repeated below. No `CONSULT_ERROR`. + +### Specify (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: open questions change the requirements → **Addressed** in part: throttle statuses, ban status, check order, image guard, `start` storage resolved. **N/A**: domain, BSUID storage, limits and leaderboard timing are the owner's, answered at the gate (D1 to D4). +- **Concern**: bind the key's `referenceId` to the WhatsApp service user → **Addressed**: services found by a `service` account row, fixed per-service permissions, an owner check at the exchange, a mis-owned-key test. +- **Concern**: "one session per user" not guaranteed → **Addressed**: session work serialized under a lock on the user row; a near-expiry session is replaced. +- **Concern**: address derivation from the BSUID and collisions → **Addressed**: the address is independent of the BSUID (final form `whatsapp-@…`); a collision is a 500. +- **Concern**: the plugin reports driver errors as `INVALID_API_KEY` and logs raw errors → **Addressed**: relation check before a 401; redacted logger. + +### Specify (Rounds 2 and 3, security architect: Codex + Claude inputs) +- **Concerns** C1 to C11 (CORS ordering, boot failure scope, budget executor, explicit `iat`, the relation check on every refusal, timeouts, alert posture, etc.) and the testability items → all **Addressed** (`329-specify-iter3-rebuttals.md`). The BSUID regex items became moot under owner decision D1 (sanity bounds only). + +### Plan (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: provisional limits → **Addressed**: owner decision 3 adopted; initial values, alerts at one third, bounds, `update-limits`. +- **Concern**: CORS through an OPTIONS fall-through → **Addressed**: the router answers every request to its path; tests. +- **Concern**: JSON parser errors → **Addressed**: a route-local error handler. +- **Concern**: eligibility must bind the BSUID → **Addressed**: the account row's `account_id` re-checked under the lock. +- **Concern**: scripts must flush Sentry → **Addressed**. +- **Concern**: missing tests (expired ban, delegation over HTTP, `rate_limit` contents, row counts after a refusal) → **Addressed**. + +### Plan (Round 2, security architect: Codex + Claude inputs) +- **Concerns** P1 to P12 (sign after commit with a pool-starvation probe, session-token generator, fail-closed probe gates, the journal bump, key-row alert reads, the criterion table, the flush banner, Content-Type, the apps/api system-only refusal, one green command per phase) → all **Addressed** (`329-plan-iter2-rebuttals.md`). + +### Phase 1 (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: probe 1b did not prove every limit field changed → **Addressed**: all four fields changed and exercised. +- **Concern**: the absolute-form target covered 2 of 5 endpoints → **Addressed**: every path plus a mixed-case variant. + +Round 2: Codex APPROVE, no issues. + +### Phase 2 (Round 1) +No concerns raised. Codex APPROVE. + +### Phase 3 (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: a padded noreply sign-up returns 400, not the spec's 409 → **Rebutted**: validation runs first in the frozen legacy order, and changing it would alter sign-up for every address. No oracle exists (padded noreply, taken and fresh addresses are identical). The spec's text was the error and was amended. + +Round 2: Codex APPROVE. + +### Phase 4 (Rounds 1 and 2) + +#### Codex (REQUEST_CHANGES, round 1) +- **Concern**: the database host was printed after the write → **Addressed**: lines are printed as produced and the host comes before `connect()`; the call order is asserted; the buffered variant turns 6 tests red. + +#### Codex (REQUEST_CHANGES, round 2) +- **Concern**: the raw key reached Sentry as a console breadcrumb → **Addressed**: breadcrumbs off and stdout printing, each guard enough alone; a production-shaped test (default integrations on) with a negative control that reproduces the leak. + +Round 3: Codex APPROVE. + +### Phase 5 (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: signing after the commit breaks the locking invariant → **Rebutted**: this is the approved plan, decided on probe evidence (signing inside hangs a pool of 2). Every window it opens is a residual the spec already accepts, and the session-outlives-token check stays under the lock. The spec sentence was amended with the security architect. +- **Concern**: claim equality did not check `kid` or `definePayload` → **Addressed** (`kid`); **Rebutted** for `definePayload`, since the `/token` JWT for the same session is `definePayload`'s output. + +Round 2: Codex APPROVE. + +### Phase 6 (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: the boot check matched the constraint by name only → **Addressed**: bound to the table and exact column order; three spoof tests, red against the old query; the production-shaped run repeated (13/13). + +Round 2: Codex APPROVE. + +### Phase 7 (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: the docs named `WHATSAPP_KEY_BOUNDS` without its ranges → **Addressed**: both docs state the ranges; the tests compute them from the constant; a negative run turns 2 red. + +Round 2: Codex APPROVE. + +### PR review (Round 1) + +#### Codex (REQUEST_CHANGES) +- **Concern**: merge is blocked because `AUTH_DEPLOYED_THROUGH` is `null`, there is no `0001` apply record, and staging is unverified → **Rebutted**: this is the agreed merge precondition, done at the rebase after spec 300's cutover. Bumping now would assert an apply that has not happened. It is stated in the PR body and the runbook. *(Superseded on 2026-10-28 by the owner's decision: no rebase step; `0001` goes to staging before the merge, and production gets the whole auth journal at its cutover. `AUTH_DEPLOYED_THROUGH` stays `null` in this PR. See Follow-up Items.)* +- **Concern**: `grant-admin --revoke` on a noreply address bypasses the reserved-domain check → **Addressed** (docs) and **Rebutted** (code). A demotion only lowers a role, it is the runbook's repair for a promoted WhatsApp user, and the system domain has never been refused on revoke either. The overclaiming wording in `arch.md`, `RELEASE.md` and the script header is narrowed to "never grants". A regression test pins grant-refused and revoke-allowed, and goes red under the suggested change. + +### PR review (Round 2) + +#### Codex (COMMENT) +- **Concern**: the runbook's post-apply check matched the constraint by name only, so a same-named constraint elsewhere could answer `t` → **Addressed**: the query is now the boot check's own (`auth.account`, `contype = 'u'`, columns in order). Run against Postgres 18 with the journal applied: `t|t`; with the constraint moved to `auth.decoy`: `t|f`; with the columns reversed: `t|f`. + +### PR gate, security architect pass 2 (route, alerts, apps/api, docs; two external reviewers read against the tree) +- **Q1 (blocking)**: an ineligible user's id reached Sentry as a console breadcrumb, because the service init kept the default Console integration → **Addressed**: breadcrumbs off service-wide. New case in `whatsapp-session-sentry.test.ts`: the user is promoted to admin, the exchange answers 500, and neither the user id, the BSUID, the key nor the token is in any envelope. Negative control: with the two options removed, the case goes red on the user id. +- **Q2**: hash prefixes in per-key alerts are not actionable → **Addressed**: the alerts carry `keyId`; the runbook says to match it against `list`. +- **Q3**: the outcome switch had no default → **Addressed**: a `never` default that answers 500. +- **Q4**: `/health` logged only `err.name` → **Addressed**: it logs `redactError`'s `{name, code}`, asserted in the recovery test. +- **Q5**: confirm the runbook's constraint query at the tip → confirmed (`auth.account`, `contype = 'u'`, ordered columns). +- **Q6**: the Bearer scheme should be case-insensitive (RFC 7235) → **Addressed**: tests for `bearer`, `BEARER` and `BeArEr` (all 3 red without the flag). +- **Q7**: format characters in a BSUID → **Addressed**: `\p{Cf}` excluded; zero-width space, zero-width joiner and bidi override give 400 (all 3 red without it). +- **Q8**: fire-once alerts carried counts that are always 1 or the threshold → **Addressed**: fields dropped; the runbook's alert section says what the event marks. +- **Q9**: comments on `EXCHANGE_LOCK_TIMEOUT` and `APIKEY_RELATION_CHECK_TTL` name `exchange-outage-probe.ts` → **Addressed**. +- **Q10**: the contract test's key comment was wrong → **Addressed** (no rate limit, so `requestCount` never moves). +- Verified: apps/auth 441/441; `whatsapp-session`, `whatsapp-session-sentry` and `boot` 92/92; doc tests 117; typecheck and lint clean. CI on 4495396 (before this pass) passed all four jobs: gitleaks, frontend, auth and api. + +### PR gate, security architect pass 1 (library and credential surface; Claude APPROVE WITH CHANGES, Codex REQUEST CHANGES) +- **R1**: the under-lock role check was a denylist (`role === 'admin'`), and `user,admin` counts as admin to the plugin → **Addressed**: an allowlist, `role !== USER_ROLE` → `ineligible: role`. Tests: `user,admin`, `admin,user`, `moderator`, `User`, empty and null. All 6 are red against the old check. +- **R2**: `--name` and `--client-id` were unrestricted, so a pasted key would be printed and sent to Sentry, and a newline injected output → **Addressed**: `[A-Za-z0-9 ._-]{1,64}`, and values holding a key prefix, matching a secret-scanning pattern, or containing a 32-letter run (a key body without its prefix; one step past the request) are refused without being echoed. Ten refusal cases on both flags, plus accepted near misses. 10 tests are red with the check bypassed. +- **R3**: the no-DSN exit-3 path depended on an uninitialised `Sentry.flush` answering false → **Addressed**: `scriptRecorder(undefined)` returns `unrecordedRecorder` (no capture, no flush), and the banner states the reason. New file `service-keys-no-dsn.test.ts` uses the real, never-initialised Sentry: exit 3, the key id in the banner, the write standing, Sentry still uninitialised. Red against the old choice. A capture with no client leaves no observable trace (`lastEventId` stays undefined; a control proved it), so the test checks the chosen path and its banner rather than the absence of a call. +- **R4**: `--confirm` for `update-limits` and `revoke` was the service user's id, so a confirm from another key's show step still wrote → **Addressed**: it is bound to the key id. The show step and rotate's revoke hints print it, and the runbook and demo harness follow. Tests: the user's id and another key's id both write nothing (2 red against the old binding). +- **Noted, no change** (per security): `AUTH_DEPLOYED_THROUGH` stays null in this PR (main sets it at the production cutover); the boot check fails open on non-`42P01` errors by design; a leaked key can create up to its daily budget of new noreply users (the spec's threat model, visible as table growth). +- Verified: apps/auth 459/459; packages/auth 252 + 1 skipped; apps/api doc and contract tests 118; typecheck and lint clean; the demo harness has the same exit tally and both checks pass. CI on 485e55f (pass 2) passed all four jobs. + +### Main architect, items A and B (the cutover applies the whole auth journal), 2026-10-28 +- **A (blocking)**: `deploy-guide.md` step 3 and `better-auth-cutover.md` step 5 applied only `0000` and checked a hardcoded 6 tables, which `0001` makes 7 → **Addressed** (0c2da21). Both now apply every journal file in `_journal.json` order, one `psql -X -v ON_ERROR_STOP=1 --single-transaction` each, before the copy. The check is `auth-journal.mjs check`, derived per migration from the objects its SQL creates (it refuses a statement kind it cannot check), so a later migration needs no runbook edit. Tests: `apps/auth/tests/cutover/auth-journal.test.ts` (the check on pglite through the whole journal, and through `0000` only, where it names `0001`), and a doc test pinning both pages' commands, their order before the copy, and no hardcoded count. It is red against the old guide (4 failed). +- **B (blocking)**: `copy.sh --dry-run` on a schema-less database applied only the static `0000` → **Addressed** (05554db). `02-import-auth.sql` includes `:dry_run_baseline`, which is `auth-journal.mjs sql` (every file in order, then an assertion that each migration is present) read from a file descriptor that `copy.sh` holds open, so no file is written. The psql test harness gained `\i`. Tests: the baseline order, and a baseline of `0000` plus the full-journal assertion that fails naming `0001` (red against the old `\ir 0000`). Real-Postgres dry run below. +- The runbooks' wording, verbatim from main, goes in the service-keys runbook's staging half (d177e80): the apply discipline, and the duplicate pre-check only where data exists. + +### Plan (Round 3, owner amendment 1, withdrawn) + +#### Codex (REQUEST_CHANGES), plus the security architect's T1-T20 and the external round (Codex and Claude, both REQUEST CHANGES) +- **Concerns**: Sentry injection for the stub sender; a claimable holder already linked; phone-number normalisation; race assertions; wiring. T1 found the placeholder SMS sender left all five plugin routes live (confirmed by probe), and T2-T20 covered supersede, holder states, the canonical form, locking, HMAC keys and more → **N/A**: the owner withdrew amendment 1 before it was built (amendment 2). Only T12 carried into the new phase 8. The record is in `329-plan-iter2-rebuttals.md` and the appendix. + +### Phase 8 (Round 1) + +#### Codex (COMMENT) +- **Concern**: the review document's summary still said seven phases, and its compliance list still described the stored phone number → **Addressed** in this review phase (summary, compliance, deviations, lessons). + +### PR review, extra Codex pass on the phase 8 diff (security architect's request, 2026-10-30) + +In the review phase, porch replayed the recorded PR reviews of 2026-10-28, so no fresh PR-level consult ran on phase 8. The security architect asked for one manually, as an extra artifact (`329-review-extra-phase8-codex.txt`), and ran their own Claude pass in parallel. + +#### Codex (COMMENT) +- **Concern**: the PR body still said only the exchange is scrubbed, but since #334 `SERVICE_SENTRY_OPTIONS.beforeSend` scrubs every apps/auth request → **Addressed**: the PR body is corrected (Changes and "Known, out of scope"). +- **Concern**: migration `0001_api_key` remains a pre-merge staging gate → **N/A**: already documented as the merge precondition; no code change. + +### PR review, security architect's Claude pass on the phase 8 increment (APPROVE WITH CHANGES, 2026-10-30) + +The full text is in `329-review-extra-phase8-security-claude.md`. +- **U1**: the journal checker's ADD COLUMN branch lost its only coverage when the phone assertions were inverted → **Addressed**: a unit test checks that `ALTER TABLE "auth"."users" ADD COLUMN "x" text;` yields the `column_name = 'x'` check. +- **U2**: the sign-up scan in `api-key.test.ts` could pass on an empty dump, and accepted either sign-up outcome → **Addressed**: a positive control (the dump contains the sign-up's email), and the observed better-auth 1.7.7 behaviour pinned (200; undeclared fields are dropped), with a comment. +- **Doc nits** → **Addressed**: the summary states phase 8's COMMENT verdict; the superseded review bullet no longer describes the deleted 400 tests; the plan's phone lines are marked superseded by phase 8, so the spec, plan and review agree. +- **Optional nits** → **Addressed**: a nested-object `phone_number` shape, the JSON-number shape registered as a needle, and a maximal valid body (a 256-character BSUID and a 501-digit phone value) answering 200 with the number in no row. + +## Lessons Learned + +### What Went Well + +- **Probes before code.** The pinning probes, on pglite and then on real Postgres, settled every library behaviour the design rested on before the code depending on it was written. The behaviours: key deletion on exhaustion without a refill, `INVALID_API_KEY` for an outage, `signJWT` without `iat`, the session-token alphabet, and the `FOR NO KEY UPDATE` semantics. Each gate had a named first consumer, so nothing downstream was built on an assumption. +- **Mutation tests on the security checks.** Every guard that matters went red when broken, in the route, the scripts, the boot check, the logger, the scan and the docs. Two mutants survived at first, and both taught something. M2 survived because of a cache, so the test now advances the clock. M7 is an equivalent mutant, recorded as such. +- **A production-shaped outage run** (real pool, `NODE_ENV=production`, database stopped under the process) owned the criteria pglite cannot show: the post-TTL 500s, the lock timeout and `/health` recovery. + +### Challenges Encountered + +- **Sentry leaks hidden by the test setup.** The key reached Sentry through console breadcrumbs, and the first test missed it because it turned default integrations off. Separately, the request-data integration attaches the body and `Authorization` to any event raised in a request. The script now has its own options and prints through stdout, the exchange has `beforeSend`, and the pre-existing leak on other paths went to the security architect. +- **Signing inside the transaction hangs a small pool.** The spec first said to sign inside the transaction. Probe 4 E2 showed a pool of 2 hanging, so the plan moved signing after the commit and the spec text followed. +- **A placeholder sender does not make an auth plugin safe.** better-auth's phone-number plugin with a `sendOTP` that throws still stored live codes, created sessions on verify, signed in by phone and password without calling the sender, and set passwords through reset-by-phone (probe, appendix). An unused plugin route is a live route. Amendment 1 was withdrawn before any of it shipped. +- **Porch's rollback resets every plan phase.** After `porch rollback plan`, phases already built and approved came back pending. Parent mode and `none` mode both dead-end for plan phases in this codev version. Normal mode replays the recorded review files, so the re-walk cost no new consults. +- **Load-induced timeouts on the shared machine.** Load averages ran up to about 280, and 30 s pglite hooks failed across all four packages. The two files that blocked this branch's green runs got a measured deadline; the rest is recorded for main. + +### What Would Be Done Differently + +- Write the Sentry tests production-shaped from the start (default integrations on, spy transport only). A `defaultIntegrations: false` test proves nothing about what production sends. +- Check the spec's literal wording against the existing guard order before writing it. The padded-409 criterion was a drafting error, caught only in phase 3's review. + +### Methodology Improvements + +- porch's `tests` check truncates its output, so a one-off failure under load names no test (phase 7). Keeping the full log would make an unreproduced failure diagnosable. +- porch: a rollback into a phased protocol should keep completed plan phases, or offer a skip; parent mode should register its `phase-review-*` gate so `porch approve` accepts it (today it emits a gate nothing can clear). +- The Gemini lane was skipped in every round (`agy` exit 3), so each "3-way" review was Codex alone, plus the security architect's own rounds on the spec and plan. A preflight that refuses to start a consult round whose lanes cannot run would surface this before a round counts as reviewed. + +## Architecture Updates + +- Routed: cold — `codev/resources/arch.md`, Authentication & Authorization — a new paragraph on service keys and the WhatsApp exchange (service identity, the issuing script, blocked plugin surface, eligibility under the lock, BSUID storage, the noreply domain, alert posture, the boot check, the runbook). Phase 8 replaced the phone-storage sentence: no phone number is stored; the exchange accepts and ignores it. The first-use refusal is now stated as system domain only, with the reason. `grant-admin.ts` is corrected to refuse both reserved domains (also in `RELEASE.md`). (Phase 7) +- Hot tier (`arch-critical.md`) unchanged. It is at its cap of 10 facts, and the service-key facts are subsystem detail that the Authentication map entry already routes to. The cross-cutting facts are untouched: two databases, the JWT shape, `ROUTER_RULES` all `false`, and migrations by hand. + +## Lessons Learned Updates + +- Routed: cold — `codev/resources/lessons-learned.md`, new section "Service API keys and the WhatsApp exchange (spec 329)": `@better-auth/api-key` 1.7.7 behaviours (no-refill exhaustion deletes the key, an outage reported as `INVALID_API_KEY` after logging the raw error, `signJWT` without `iat`); the `FOR UPDATE` versus `FOR NO KEY UPDATE` hazard on a row other tables reference; pool starvation from a pooled call inside an open transaction; Sentry's console breadcrumbs and request data, and why `defaultIntegrations: false` hides them; drizzle putting the SQLSTATE on `cause`; a mutant surviving behind a cache. +- Routed: cold — the same section, two more: an auth plugin's routes are live even with a placeholder sender (probe it before relying on it), and porch's rollback to plan resets every plan phase (parent and `none` modes dead-end; normal mode replays the recorded reviews). +- Hot tier (`lessons-critical.md`): no lesson added, since it is at its cap of 10. The map entry for spec 300 now also names spec 329, so the map stays at its current count. Candidate for promotion at the next MAINTAIN: "a Sentry test with default integrations off cannot show what production sends". + +## Flaky Tests + +### `apps/auth/tests/secrets.test.ts` and `apps/auth/tests/cutover/copy.test.ts` (fixed with a targeted deadline) + +Both are spec 300 tests (the secrets guard and the cutover copy). Their setup hooks (`beforeAll` in `secrets.test.ts`, which starts two services; `beforeEach` in `copy.test.ts`, which builds two pglite databases) crossed the 30 s `hookTimeout` when the whole monorepo's tests ran at once. They pass when apps/auth runs alone (261/261). **Not skipped** (security architect): each hook now has its own 100 s deadline, in those two files only (`chore(test)` commit in phase 2). + +**Conditions: a real shared-machine run, no artificial load harness.** The machine runs other builders' sessions and iOS simulators. Load averages (1/5/15 min) sampled during the measurement: 20.75 / 50.01 / 71.33, 54.04 / 46.78 / 55.22, 22.15 / 41.07 / 51.18. Each run is a full root run with `--force --continue`, alternating `turbo run test` and `turbo run test typecheck lint`. + +**Timings (N = 6, hooks given a temporary 600 s deadline and timed):** slowest per run, in ms. + +| Run | Tasks | `secrets` beforeAll | `copy` beforeEach (max) | Both target files | +|---|---|---|---|---| +| 1 | test | 35,465 | 39,005 | pass | +| 2 | test, typecheck, lint | 34,839 | 36,490 | pass | +| 3 | test | 31,703 | 30,088 | pass | +| 4 | test, typecheck, lint | 42,296 | 47,098 | pass | +| 5 | test | 27,097 | 30,239 | pass | +| 6 | test, typecheck, lint | 40,547 | 43,910 | pass | + +Maximum 47 s; the deadline is 100 s (about 2x, above the architect's 60 s floor). + +**Proof: 30 s versus 100 s, same conditions, counted on the two target files only** (the rest of the suite is red from machine load regardless, see below): + +``` +before run 1 [test] deadline=30_000 secrets=RED copy=RED slowest=[copy=9296 copy=32440 ] +before run 2 [test typecheck lint] deadline=30_000 secrets=RED copy=RED slowest=[copy=7926 copy=41851 ] +before run 3 [test] deadline=30_000 secrets=RED copy=green slowest=[copy=29012 secrets=31456 ] +before run 4 [test typecheck lint] deadline=30_000 secrets=RED copy=RED slowest=[copy=10031 copy=41717 ] +before run 5 [test] deadline=30_000 secrets=green copy=green slowest=[copy=25698 secrets=26954 ] +after run 1 [test] deadline=100_000 secrets=green copy=green slowest=[copy=36150 secrets=39634 ] +after run 2 [test typecheck lint] deadline=100_000 secrets=green copy=green slowest=[secrets=47493 copy=53506 ] +after run 3 [test] deadline=100_000 secrets=green copy=green slowest=[secrets=12502 copy=15275 ] +after run 4 [test typecheck lint] deadline=100_000 secrets=green copy=green slowest=[secrets=20134 copy=21117 ] +after run 5 [test] deadline=100_000 secrets=green copy=green slowest=[copy=14771 secrets=14982 ] +``` + +At 30 s: `secrets` red 4/5, `copy` red 3/5. At 100 s: both green 5/5 (slowest hook 53.5 s). + +### Phase 7: one unidentified `@ansari/auth#test` failure inside porch's check (not reproduced) + +After review iteration 1 (docs and apps/api tests only; no packages/auth file changed), porch's `tests` check failed once with `@ansari/auth#test ... exited (1)` after 78.9 s, and porch's truncated output did not name the test. The suite passed alone right after (246 passed, 1 skipped), and the full root command, rerun with its log kept, passed (`@ansari/auth` 246 + 1 skipped, `ansari-api` 1132 + 3 skipped, exit 0). Load averages at the rerun's start: 134 (1 min), 280 (5 min), 252 (15 min). That is consistent with the load timeouts below, but I am not naming a cause I did not see. Nothing was skipped or changed to get past it. + +### Other files that tripped 30 s timeouts in the same runs (evidence for main; not fixed here) + +On the same shared machine, with no harness, **other files hit the 30 s hook or test deadline in 5 of the 6 timing runs** (runs 1, 2, 4, 5 and 6; only run 3 was clean). Extracted from the vitest failure summaries of each run's output (` :: `): + +``` +== timing run 1 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +== timing run 2 +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +== timing run 3 +== timing run 4 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/unit/logger.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/layer.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/require-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/resolve-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-api tests/migration/window-migration.test.ts :: Hook timed out in 30000ms +ansari-api tests/thread-get-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +== timing run 5 +@ansari/auth tests/config/rate-limit.test.ts :: Test timed out in 30000ms +ansari-api tests/migration/window-migration.test.ts :: Test timed out in 30000ms +== timing run 6 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/unit/logger.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/layer.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/require-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/resolve-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +``` + +### Under #301's artificial load (28 busy loops on 14 cores), 3 runs, for comparison + +Without `--continue`, turbo stops at the first failing package, so each run shows only the first package to fail: run 1 the frontend (`lib/worklets.test.ts`, `Test timed out in 5000ms`); run 2 apps/api (`tests/migration/window-migration.test.ts`, `Test timed out in 30000ms`); run 3 twelve `packages/auth` files. Hook times there: `secrets` 37 s, `copy` 36 s and 55 s. + +``` +timing run 1 exit=1 fails=[] times=[] +timing run 2 exit=1 fails=[] times=[copy=5644 copy=5992 copy=6557 copy=6943 copy=7109 copy=7159 copy=7191 copy=7625 copy=7780 copy=7803 copy=7888 copy=8016 copy=8298 copy=8468 copy=8672 copy=9121 copy=10567 copy=11835 copy=11842 copy=14900 copy=15385 copy=36043 secrets=37399 ] +timing run 3 exit=1 fails=[@ansari/auth:test: FAIL tests/auth.http.test.ts [ tests/auth.http.test.ts ];@ansari/auth:test: FAIL tests/auth.integration.test.ts [ tests/auth.integration.test.ts ];@ansari/auth:test: FAIL tests/config/api-key.test.ts [ tests/config/api-key.test.ts ];@ansari/auth:test: FAIL tests/config/credentials.test.ts [ tests/config/credentials.test.ts ];@ansari/auth:test: FAIL tests/config/options.test.ts [ tests/config/options.test.ts ];@ansari/auth:test: FAIL tests/config/rate-limit-store.test.ts [ tests/config/rate-limit-store.test.ts ];@ansari/auth:test: FAIL tests/config/rate-limit.test.ts [ tests/config/rate-limit.test.ts ];@ansari/auth:test: FAIL tests/config/sign-up.test.ts [ tests/config/sign-up.test.ts ];@ansari/auth:test: FAIL tests/config/tokens.test.ts [ tests/config/tokens.test.ts ];@ansari/auth:test: FAIL tests/migration/auth-journal-parity.test.ts > Drizzle schema vs the auth journal;@ansari/auth:test: FAIL tests/schema/auth-schema.test.ts [ tests/schema/auth-schema.test.ts ];@ansari/auth:test: FAIL tests/unit/logger.test.ts > createAuth uses the redacted logger > a non-error log survives with its level and message (a disabled logger fails this);] times=[copy=55167 ] +``` + +This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under contention), passed to the main architect as a follow-up; this PR fixes only the two files that blocked its own green runs. + +## Follow-up Items + +- **Migration `0001` (owner decision, 2026-10-28).** **Staging, before the merge:** the duplicate check on `auth.account (provider_id, account_id)`, then `0001` applied with `psql -X -v ON_ERROR_STOP=1 --single-transaction` and verified with the runbook's bound query. apps/auth refuses to boot without it, so the apply precedes the merge. **Production:** at the production cutover, the full auth journal as it stands then (`0000`, `0001` and any later migration) is applied in order before the promoted code deploys, owned by `docs/cutover/better-auth-cutover.md`. `develop` is not frozen. `AUTH_DEPLOYED_THROUGH` stays `null`, with no `auth-journal-applied` record in this PR (security architect; main to confirm): it is production truth, and main sets it at the cutover. apps/api's `DEPLOYED_THROUGH` is not touched. +- **Staging smoke** (#327's builder): a phone-visible user, a phone-hidden user, and 5 messages in 1 s, using a key from the script. Expected: 5 answers, 1 user, 1 session. Recorded as a comment on PR #327. #327 also switches to `Authorization: Bearer` and the error shapes in the contract summary, and carries spec 321's amendment. +- ~~**Pre-existing Sentry request-data leak** on apps/auth's other paths~~ **Fixed by #334** (merged to develop 2026-10-29). When develop was merged into this branch, `alerts.ts` was resolved as the security architect specified: one `SERVICE_SENTRY_OPTIONS` whose `beforeSend` is #334's unconditional `scrubRequestEvent`, a superset of this PR's exchange-only `scrubExchangeEvent`, which was deleted. `WHATSAPP_SESSION_PATH` and the breadcrumb reasons were kept. The 'another path is left as it is' test is inverted (every path is now scrubbed), and `SCRIPT_SENTRY_OPTIONS` notes that a CLI needs no `beforeSend`. Both apps' Sentry suites pass (apps/auth 90, apps/api 4). Removing `beforeSend` turns 3 red, including this PR's exchange case. +- **#327 stops sending `phone_number`** (the security architect asks its author through the community architect); then a later PR removes the field from the exchange's contract, and spec 321's "never sent on" rule applies again. +- **Monorepo-wide pglite 30 s timeouts under load** (evidence under Flaky Tests), for main. +- **#314 (MCP):** the verify route at the public origin, the apps/api client, and its own key bounds (`ansari-mcp` borrows `WHATSAPP_KEY_BOUNDS` until then). +- **Plugin upgrade:** `@better-auth/api-key` past 1.7.7 needs core ≥ 1.7.14; bump them together and rerun the pinning probes. +- **Leaderboard** keeps its static secret for now (owner decision 4: migrate later). + +## Appendix: verification evidence + +Probe outputs, per-phase notes and the pre-existing Sentry reproduction, recorded as each phase ran. + +### Merge precondition: `0001_api_key` applied to staging, 2026-10-30 + +The owner applied `packages/auth/drizzle/0001_api_key.sql` to the staging auth database through the staging Tailscale forwarder, with `psql -X -v ON_ERROR_STOP=1 --single-transaction`, and pasted the output: + +``` +CREATE TABLE +ALTER TABLE +CREATE INDEX +CREATE INDEX +CREATE INDEX +ALTER TABLE +t|t +``` + +`t|t` is the runbook's bound verification query: `auth.apikey` exists, and `account_provider_id_account_id_unique` is a unique constraint on `auth.account` over `(provider_id, account_id)`, in order. The duplicate check's own output was not pasted. Its question is answered by the apply itself: adding that constraint inside the single transaction would have failed, and rolled everything back, on any duplicate pair. The precondition for merging PR #331 is met. Production gets the whole auth journal at its cutover (`docs/cutover/better-auth-cutover.md`). + +### Phase 8: remove the phone number (owner amendment 2) (notes) + +- **Source.** `schema.ts` drops `users.phoneNumber` and `createAuth` drops the `phone_number` additional field. In `whatsapp.ts`, `resolveOrCreateWhatsappUser` and `issueWhatsappToken` take no phone, and the update under the lock is gone. The route reads `bsuid` only: a `phone_number` in any shape is accepted and never read. `EXCHANGE_BODY_LIMIT` is unchanged, with room for the field until #327 stops sending it. +- **Migration.** `0001` was regenerated in place as `0001_api_key` (`drizzle-kit generate --name api_key`, after removing the old entry and snapshot). Its diff from the old `0001_api_key_phone.sql` is exactly one line, the removed `ALTER TABLE "auth"."users" ADD COLUMN "phone_number" text`. The tag is renamed in every test and doc. Historical outputs quoted in this review keep the old name. `AUTH_DEPLOYED_THROUGH` stays `null`. +- **New tests:** + - `packages/auth/tests/schema/better-auth-model.test.ts`: `getAuthTables` with the configured options, against the journal-built database. It covers all models (`apikey`'s 21 fields included), with one documented deviation, `apikey.referenceId` as uuid with a foreign key. Its negative control, a dropped `key` column and a retyped `name`, reports both. + - `apps/auth/tests/no-phone-source.test.ts`: 0 hits in 28 files, comments excluded. Its negatives: a planted read and a string are caught; comments are not. + - The route's accept-and-ignore cases: digits, a leading `+`, a JSON number, `null`, empty, 33 characters, letters and spaces. Each gets a 200, and an all-tables scan of `auth` then finds the value in no row; the scan also asserts that its own BSUID is present, so it cannot pass vacuously. The end-to-end test scans the auth side too. +- **Kept as needles:** the never-log capture, the Sentry scrub test, the raw-response-bytes assertion, `/me` and the rate-limit key test. +- **Mutation checks** on the route, each reverted: + - a 400 on a numeric `phone_number` turns the accept test and the source check red (2 failed); + - logging the field turns the log-capture test and the source check red (2 failed). +- **Probes** after the edits, on Postgres 18 (throwaway container): library probes 3/3, the lock probe 12/12. + +``` +Probes 2 and session token against Postgres 18.6 (Debian 18.6-1.pgdg13+2) at 127.0.0.1 (2026-10-29T23:43:07.444Z) +PASS 2 signJWT({sub, sid, iat, exp}) matches a /api/auth/token JWT; the passed exp is honoured +PASS 2 without iat the claim set differs (why the exchange passes it) +PASS session token: generateRandomString(32, a-z, A-Z, 0-9) has the shape createSession writes +3/3 steps as expected. +Probe 4 against Postgres 18.6 (Debian 18.6-1.pgdg13+2) at 127.0.0.1 (2026-10-29T23:43:08.485Z) +PASS A same-transaction session insert under FOR NO KEY UPDATE: committed without waiting; 1 session +PASS B concurrent ban waits for the exchange, then deletes its session: ban waited: true; ban finished after exchange commit: true; sessions left: 0 +PASS C session insert from another connection does not wait on FOR NO KEY UPDATE: insert completed while the lock was held +PASS C' contrast: FOR UPDATE blocks another connection's session insert (the hazard): other connection's insert: 57014 (57014 = cancelled by statement_timeout, i.e. it waited) +PASS D lock_timeout turns a held lock into 55P03: waiting exchange got: 55P03 +PASS E1 signing AFTER commit, pool max 2: 10/10 finished +PASS E2 signing INSIDE the transaction, pool max 2, no connect timeout: deadline: the run HUNG (10000 ms), as expected for signing inside +PASS E2 signing INSIDE the transaction, pool max 2, connect timeout 2000 ms: 0/10 finished; failures: Error (loud failure, no hang) +PASS F1 real functions: 10 concurrent exchanges for one new BSUID: distinct users: 1; distinct sids: 1; whatsapp rows: 1; sessions: 1 +PASS F2 real functions: another connection's open session insert does not block the exchange: exchange outcome while the insert was open: issued +PASS F3 real functions: a held ban update fails the exchange with 55P03 within its lock_timeout: exchange got 55P03 after 2014 ms (lock_timeout 2000 ms); after the ban committed: banned +PASS F4 real functions: 10 concurrent new users on a pool of 2 (sign after commit): 10/10 issued +12/12 steps as expected. +``` + +- **Verified:** typecheck and lint 13/13; `@ansari/auth` 254 + 1 skipped; `ansari-auth` 472; `ansari-api` 1139 + 3 skipped. + +### Re-walk of phases 1 to 7 after the porch rollback to plan, 2026-10-30 + +`porch rollback 329 plan` re-extracts the plan and resets every plan phase to pending, so after the plan gate porch required phases 1 to 7 to be walked again. Parent mode and `none` mode both dead-end for plan phases in this codev version (`dist/commands/porch/next.js`), so, with the security architect's agreement, the re-walk used normal mode: porch replayed the recorded review files of 2026-10-28, iteration by iteration. It ran no new consult (none was emitted), and each step ran the build and test checks, all green. The phase 8 work was stashed so the checks ran on the committed code only. + +- phase_1: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 REQUEST_CHANGES + rebuttal, iter 2 APPROVE). +- phase_2: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 APPROVE). +- phase_3: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 REQUEST_CHANGES + rebuttal, iter 2 APPROVE). +- phase_4: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iters 1 and 2 REQUEST_CHANGES + rebuttal, iter 3 APPROVE). +- phase_5: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 REQUEST_CHANGES + rebuttal, iter 2 APPROVE). +- phase_6: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 REQUEST_CHANGES + rebuttal, iter 2 APPROVE). +- phase_7: re-walked after the porch rollback; code unchanged since a2a16745; recorded reviews replayed (iter 1 REQUEST_CHANGES + rebuttal, iter 2 APPROVE). + +The consultation override used along the way (`modelsByType`) was removed before phase 8; `.codev/config.json` is untracked and back to `models: codex, gemini`. + +### Phase 8, step 1: `0001` applied nowhere (T12), 2026-10-30 + +The owner, who operates staging, wrote (relayed verbatim by the security architect on 2026-10-30): "nothing has been executed against staging as we didn't merge yet." + +Applying `0001` to staging is the pre-merge step, and the merge has not happened, so `0001` was never applied to staging. Production has nothing applied (its cutover has not run). The two read-only queries main asked for (`auth-journal.mjs check` against staging, and a direct look for `auth.apikey` and `auth.users.phone_number`) were **not run**, because the apply step itself never ran; the security architect accepted the owner's record on that basis. Decision: `0001` is regenerated in place as `0001_api_key`, with no `phone_number`. The `0002` fallback is not needed. + +### Owner amendment 1 (phone-number plugin, claiming by number): withdrawn, the record + +On 2026-10-30 the owner folded better-auth's phone-number plugin and claiming an account by phone number into this project (spec amendment 1, commits 1d84f91d and ca1452db; plan phase 8, d3b92b00). Porch rolled back from review to plan. Codex's plan consult (iteration 2) and the security architect's own review and external round (Codex and Claude, both REQUEST CHANGES) then found that the plan could not be built safely as asked. The security architect's 20 findings are in `codev/projects/329-*/329-plan-iter2-input-security-T1-T20.md`, the external texts in `329-plan-iter2-input-s5-{codex,claude}.md`. + +The blocking one, T1, was confirmed by probe before anything was built. With the plugin enabled and a placeholder sender that throws: +- `send-otp` stores a live code before the sender fails; +- `verify` with that code creates a session; +- phone sign-in never calls the sender; +- reset-by-phone stores a code without the sender, and with it creates a password on an account that had none. + +A before hook on the five paths closes all of them. The owner then withdrew the amendment: "we don't need to deal with this complexity for now, change of plans, drop phone number entirely, whatsapp id is enough". That became amendment 2 (spec §5): no phone number stored at all, `phone_number` accepted and ignored. Of the 20 findings only T12 carries into it (verify that `0001` is applied nowhere, with `0002` as the fallback). + +Probe output (`packages/auth/scripts/probes/phone-plugin-probe.ts`, better-auth 1.7.7, in-memory adapter, run 2026-10-30; the session token in `verify`'s body is redacted; the script itself is not committed, since amendment 2 has no plugin): + +``` +== A: plugin enabled, no short-circuit +send-otp (known): 500 | writes: user.create.before,sendOTP called | rows before {"user":1,"session":0,"account":1,"verification":0} after {"user":1,"session":0,"account":1,"verification":1} + a verification row for the number exists after the sender threw: true +verify (known, the stored code): 200 {"status":true,"token":"","u | set-cookie: true | sessions 0 -> 1 | hooks: user.update.before +sign-in/phone-number (known, right password): 200 | set-cookie: true | sendOTP called: false +sign-in/phone-number (unknown): 401 {"message":"Invalid phone number or password","code":"INVALID_PHONE_NUMBER_OR_PASSWORD"} +sign-in/phone-number (known, wrong password): 401 {"message":"Invalid phone number or password","code":"INVALID_PHONE_NUMBER_OR_PASSWORD"} +request-password-reset known: 200 {"status":true} | unknown: 200 {"status":true} | verification rows 0 -> 2 | sendOTP called: false +send-otp unknown: 500 | known was: 500 +== A2: reset-password on a user with no credential row +reset-password with the stored code: 200 {"status":true} | accounts whatsapp -> whatsapp,credential +== B: a before hook that throws on the five paths +/phone-number/send-otp: known 400 {"message":"Invalid request"} | unknown 400 {"message":"Invalid request"} | set-cookie false +/phone-number/verify: known 400 {"message":"Invalid request"} | unknown 400 {"message":"Invalid request"} | set-cookie false +/sign-in/phone-number: known 400 {"message":"Invalid request"} | unknown 400 {"message":"Invalid request"} | set-cookie false +/phone-number/request-password-reset: known 400 {"message":"Invalid request"} | unknown 400 {"message":"Invalid request"} | set-cookie false +/phone-number/reset-password: known 400 {"message":"Invalid request"} | unknown 400 {"message":"Invalid request"} | set-cookie false +rows before {"user":1,"session":0,"account":1,"verification":1} after {"user":1,"session":0,"account":1,"verification":1} | sendOTP called: false | hooks: user.create.before +== C: plugin endpoints +signInPhoneNumber:/sign-in/phone-number sendPhoneNumberOTP:/phone-number/send-otp consumePhoneNumberOTP:(no path) verifyPhoneNumber:/phone-number/verify requestPasswordResetPhoneNumber:/phone-number/request-password-reset resetPasswordPhoneNumber:/phone-number/reset-password +``` + +### Main's item B: `copy.sh --dry-run` against an empty auth database (real Postgres 18), 2026-10-28 + +Two throwaway `postgres:18` containers on loopback: the app database migrated through apps/api's `0010` with two seeded legacy users, and an empty auth database. `copy.sh --dry-run --allow-local` was run under a pseudo-terminal (it refuses a redirected stdout). The notice comes from inside the rolled-back transaction. `0001_api_key_phone` is reported applied only when every object its SQL creates exists, and that includes `auth.apikey` and `auth.users.phone_number` (the generated check names `to_regclass('"auth"."apikey"')` and `column_name = 'phone_number'`). After the run, the auth database has no `auth` schema and no tables. + +``` +== auth database before: schemas named auth = 0 +== copy.sh --dry-run --allow-local +copy.sh: LOCAL TEST RUN (--allow-local): loopback databases only; the Tailscale forwarder host check is skipped +copy.sh: WARNING: APP_DATABASE_URL carries no sslmode=require (runbook step 1 records whether TLS is offered through the forwarder) +copy.sh: WARNING: AUTH_DATABASE_URL carries no sslmode=require (runbook step 1 records whether TLS is offered through the forwarder) +copy.sh: DRY RUN: the copy runs in one transaction and is rolled back; nothing is committed +copy.sh: DRY RUN: the auth database has no schema; the whole auth journal (_journal.json order) is applied inside the transaction and rolled back with it +copy.sh: dry run: copying 2 non-system user(s) (app → auth) +psql:/dev/fd/9:149: NOTICE: auth journal check: applied inside this transaction: 0000_auth_schema, 0001_api_key_phone +copy.sh: dry run: rolled back; the auth database is as it was +copy.sh: auditing +-- app +rows|2 +id_checksum|e455da112cf76f48cf8a3955e023acfc +email_checksum|d747e16f0846f0586fb4ea0bf2ac688c +hash_checksum|12acf6c898b966091e96498568cb87e6 +credentials|2 +system_domain_rows|0 +-- auth +rows|2 +id_checksum|e455da112cf76f48cf8a3955e023acfc +email_checksum|d747e16f0846f0586fb4ea0bf2ac688c +hash_checksum|12acf6c898b966091e96498568cb87e6 +credentials|2 +system_domain_rows|0 +copy.sh: dry run audit OK (nothing committed) +copy.sh exit=0 +== auth database after: schemas named auth = 0, tables outside the catalogs = 0 +``` + +### Main's item A: the runbook's journal apply and check (real Postgres 18), 2026-10-28 + +The two commands from `better-auth-cutover.md` step 5 and `deploy-guide.md` step 3, against a fresh empty auth database, then a negative (the objects of `0001` dropped): + +``` +$ (empty auth database) tables outside the catalogs: 0 +$ for f in $(node apps/auth/scripts/cutover/auth-journal.mjs files); do psql ... --single-transaction -f "$f" || break; done + applying packages/auth/drizzle/0000_auth_schema.sql + applying packages/auth/drizzle/0001_api_key_phone.sql +$ node apps/auth/scripts/cutover/auth-journal.mjs check | psql "$AUTH_DATABASE_URL" -X -q -At -v ON_ERROR_STOP=1 +0000_auth_schema|t +0001_api_key_phone|t +exit=0 +$ (negative) drop auth.apikey and auth.users.phone_number, then the same check +0000_auth_schema|t +0001_api_key_phone|f +ERROR: auth journal check: not applied: 0001_api_key_phone +exit=3 +``` + +### Secret scan (gitleaks), review phase, 2026-10-28 + +Pinned gitleaks 8.24.3, the repo's `.gitleaks.toml`, over the branch's history at 5ce51d8: +`gitleaks git --log-opts=origin/develop..HEAD --config /.gitleaks.toml --redact=90 `. + +``` +8.24.3 +INF 94 commits scanned. +INF scanned ~767236 bytes (767.24 KB) in 444ms +INF no leaks found +exit=0 +``` + +Negative control: the same scan with only the two spec 329 allowlist entries removed exits 1 with exactly the four +findings CI reported, all in history and none at the tip (`grep` for either literal over `packages`, `apps`, `docs` +and `codev` at the tip: 0 hits): + +``` +generic-api-key packages/auth/scripts/probes/probe-db.ts 69 90aff8c +generic-api-key packages/auth/scripts/probes/probe-db.ts 76 90aff8c +generic-api-key packages/auth/scripts/probes/exchange-lock-probe.ts 240 427f8fd +generic-api-key packages/auth/scripts/probes/exchange-lock-probe.ts 247 427f8fd +``` + +The probes were rerun after `setProbeEnv` moved to `setAuthEnv`: `library-probes.ts` on Postgres 18, 5/5 steps as expected. + +### Probe outputs + +#### Phase 1: probe 4 (real Postgres), 2026-10-28 + +Run against a throwaway `postgres:18` container on 127.0.0.1 with +`packages/auth/scripts/probes/exchange-lock-probe.ts` (moved from the plan's +`apps/auth/scripts/probes/` because apps/auth has no direct `pg` / `drizzle-orm` +dependency). The `pg` deprecation warning comes from clients left checked out by +the deliberately hung E2 run. + +``` +Probe 4 against Postgres 18.6 (Debian 18.6-1.pgdg13+2) at 127.0.0.1 (2026-10-28T06:04:19.563Z) +PASS A same-transaction session insert under FOR NO KEY UPDATE: committed without waiting; 1 session +PASS B concurrent ban waits for the exchange, then deletes its session: ban waited: true; ban finished after exchange commit: true; sessions left: 0 +PASS C session insert from another connection does not wait on FOR NO KEY UPDATE: insert completed while the lock was held +PASS C' contrast: FOR UPDATE blocks another connection's session insert (the hazard): other connection's insert: 57014 (57014 = cancelled by statement_timeout, i.e. it waited) +PASS D lock_timeout turns a held lock into 55P03: waiting exchange got: 55P03 +(node:76887) DeprecationWarning: Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead. +(Use `node --trace-deprecation ...` to show where the warning was created) +PASS E1 signing AFTER commit, pool max 2: 10/10 finished +PASS E2 signing INSIDE the transaction, pool max 2, no connect timeout: deadline: the run HUNG (10000 ms), as expected for signing inside +PASS E2 signing INSIDE the transaction, pool max 2, connect timeout 2000 ms: 1/10 finished; failures: Error (loud failure, no hang) +8/8 steps as expected. +``` + +#### Phase 1: pinning probes (pglite, `packages/auth/tests/config/api-key.test.ts`) + +All green (16 tests): +- Probe 1: a script-created key verifies; the raw key appears in no column; `start` is null; `key` equals `defaultKeyHasher(raw)`; permissions and metadata round-trip; no expiry. +- Probe 1b: `auth.api.updateApiKey` server-side changes `rateLimitMax`, the window and the refill, enforced at the next verify. +- Probe 2: `auth.api.signJWT` with explicit `{sub, sid, iat, exp}` has the same claim keys, `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, and honours the passed `exp`. Without `iat`, the claim set lacks `iat`. +- Probe 4a: `phone_number` is absent from the sign-in and get-session responses. Sign-up with `phone_number` is **refused** with 400 `{"message":"phone_number is not allowed to be set","code":"FIELD_NOT_ALLOWED"}` and nothing is stored (the spec allowed refused or ignored). +- Session-token probe: `internalAdapter.createSession` writes a 32-character `[A-Za-z0-9]` token; `generateRandomString(32, 'a-z', 'A-Z', '0-9')` from `better-auth/crypto` produces the same shape (without the explicit alphabet it also emits `-` and `_`). +- Plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval (both cycles); the window limit refuses and keeps; disabled is refused and kept; expired is refused and deleted; a wrong permission spends no budget; a key is never a session at `/get-session` or `/token`. + +**Gates:** before phase 4 (probes 1, 1b) and before phase 5 (probes 2, 4a, session token, probe 4): all match the plan. + +#### Gate before phase 5: rerun on real Postgres, 2026-10-28 + +At the security architect's request, every gate-before-phase-5 probe was rerun +on real Postgres (a fresh `postgres:18` container on 127.0.0.1) before any phase 5 +code, with the output pasted verbatim. Probes 2, 4a and the session-token probe +had only run on pglite; `packages/auth/scripts/probes/library-probes.ts` now runs them +against Postgres too. It prints claim names, headers and shapes, never a token. +The setup shared with probe 4 (URL guard, schema reset, env) moved to +`packages/auth/scripts/probes/probe-db.ts`. Both scripts are run as +`pnpm --filter ansari-auth exec tsx ../../packages/auth/scripts/probes/