diff --git a/.env.example b/.env.example index bf232d27..e6347b5d 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,17 @@ DATABASE_URL=postgres://rootmail:rootmail@localhost:5432/rootmail # the VPC. REDIS_URL=redis://localhost:6379 +# Authentication content policy (experimental; keep disabled until release gates pass). +# Separate non-persistent, bounded Valkey, no replicas/backups. Never use REDIS_URL. +# See docs/supabase-auth-integration.md before configuring either service. +AUTH_MESSAGES_ENABLED=false +# Exact workspace/client pairs on BOTH API and worker; [] denies every scope. +AUTH_MESSAGE_SCOPES=[] +# Example: [{"workspaceId":"ws_pilot","subTenantId":null}] +# AUTH_CONTENT_REDIS_URL=redis://127.0.0.1:6395 +# AUTH_CONTENT_KEY= # independent 32-byte base64 key from secret storage +# AUTH_SES_CONFIGURATION_SET= # DELIVERY/BOUNCE/COMPLAINT; no OPEN or CLICK + # API server API_PORT=4000 API_HOST=0.0.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c468540..afe071bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: env: DATABASE_URL: postgres://rootmail:rootmail@localhost:5432/rootmail REDIS_URL: redis://localhost:6379 + AUTH_CONTENT_REDIS_URL: redis://127.0.0.1:6395 MAIL_PROVIDER: mock # Explicit, because the DEFAULT changed. This used to rely on # DNS_VERIFY_MODE defaulting to "mock"; when that default was flipped to @@ -91,6 +92,8 @@ jobs: - run: pnpm install --frozen-lockfile - name: Migrate database run: pnpm db:migrate + - name: Start isolated non-persistent auth-content test vault + run: docker run -d --name rootmail-auth-content-ci -p 127.0.0.1:6395:6379 valkey/valkey:7.2-alpine valkey-server --save '' --appendonly no --maxmemory 64mb --maxmemory-policy noeviction - name: Unit and isolation tests run: pnpm exec turbo run test --env-mode=loose - name: Quota and end-to-end smoke diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 8e7d15bd..182010e4 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -6,11 +6,13 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/api/package.json apps/api/package.json COPY packages/core/package.json packages/core/package.json COPY packages/db/package.json packages/db/package.json +COPY packages/supabase/package.json packages/supabase/package.json RUN pnpm install --prod --frozen-lockfile COPY apps/api/src apps/api/src COPY packages/core/src packages/core/src COPY packages/db/src packages/db/src COPY packages/db/migrations packages/db/migrations +COPY packages/supabase/src packages/supabase/src FROM node:22-alpine AS runtime ENV COREPACK_HOME=/opt/corepack diff --git a/apps/api/package.json b/apps/api/package.json index 1d022f65..a59cac32 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,7 +19,7 @@ "cleanup:test-users": "tsx scripts/cleanup-test-users.ts", "staff:reset-link": "tsx src/cli/staff-reset-link.ts", "beta:migrate-senders": "tsx src/cli/beta-sender-migrate.ts", - "test": "LOG_LEVEL=silent node --import tsx --test \"src/**/*.test.ts\"" + "test": "LOG_LEVEL=silent node --import tsx --test --test-concurrency=1 \"src/**/*.test.ts\"" }, "dependencies": { "@anthropic-ai/sdk": "^0.104.1", @@ -32,11 +32,13 @@ "@node-saml/node-saml": "^5.0.1", "@rootmail/core": "workspace:*", "@rootmail/db": "workspace:*", + "@rootmail/supabase": "workspace:*", "bullmq": "^5.34.6", "drizzle-orm": "^0.38.3", "fastify": "^5.2.1", "ioredis": "^5.4.2", "mailparser": "^3.9.9", + "postgres": "^3.4.5", "stripe": "^22.2.1", "tsx": "^4.19.2", "zod": "^3.24.1" diff --git a/apps/api/src/cli/supabase-hook-server.ts b/apps/api/src/cli/supabase-hook-server.ts new file mode 100644 index 00000000..b2c46d2e --- /dev/null +++ b/apps/api/src/cli/supabase-hook-server.ts @@ -0,0 +1,40 @@ +import postgres from "postgres"; +import { readFile } from "node:fs/promises"; +import { createPostgresHookLedger, createSupabaseEmailHook } from "@rootmail/supabase"; +import { createAuthHookServer } from "../lib/auth-hook-http"; + +// Separate process/env from the API: only a pilot key, hook secret/HMAC and the +// limited ledger DB role. Never inherit the API's full environment file. +async function main() { + const raw = process.env.SUPABASE_HOOK_CONFIG_JSON; + const databaseUrl = process.env.HOOK_DATABASE_URL; + if (!raw || !databaseUrl) throw new Error(); + const url = new URL(databaseUrl); + if (!["postgres:", "postgresql:"].includes(url.protocol)) throw new Error(); + const ca = process.env.HOOK_DATABASE_CA_FILE ? await readFile(process.env.HOOK_DATABASE_CA_FILE, "utf8") : undefined; + const database = postgres(databaseUrl, { max: 2, connect_timeout: 1, ssl: { rejectUnauthorized: true, ...(ca ? { ca } : {}) }, + connection: { statement_timeout: 1000 }, onnotice: () => {} }); + const ledger = createPostgresHookLedger((query, values) => database.unsafe(query, [...values])); + const config = JSON.parse(raw); + // This listener is specifically the controlled native Sprout pilot. Admission + // remains independently guarded by Rootmail's exact-scope/recipient checks. + if (config.deliveryMode !== "code_only" || !Array.isArray(config.allowedRecipients) || !config.allowedRecipients.length) throw new Error(); + const hook = createSupabaseEmailHook({ ...config, ledger, budgetMs: 4500 }); + await database`select 1 from rootmail_auth_hook.events limit 1`; + const server = createAuthHookServer(hook); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(4011, "0.0.0.0", resolve); + }); + const shutdown = () => { + server.close(); server.closeIdleConnections(); + const timer = setTimeout(() => process.exit(0), 6000); timer.unref(); + void database.end({ timeout: 5 }).finally(() => process.exit(0)); + }; + process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown); +} +void main().catch(() => { + // JSON/DB/SDK errors may embed secrets. This is intentionally fixed text. + console.error("Auth hook startup failed; check server configuration and ledger access"); + process.exit(1); +}); diff --git a/apps/api/src/lib/auth-hook-http.test.ts b/apps/api/src/lib/auth-hook-http.test.ts new file mode 100644 index 00000000..a4f756a5 --- /dev/null +++ b/apps/api/src/lib/auth-hook-http.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { request as nodeRequest } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createAuthHookServer } from "./auth-hook-http"; + +test("dedicated hook listener preserves signed raw bytes and refuses duplicate headers", async () => { + const seen: string[] = []; + const server = createAuthHookServer(async request => { + seen.push(await request.text()); + assert.equal(request.headers.get("webhook-id"), "fixture"); + return Response.json({}); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + assert.equal((await fetch(`${origin}/health`)).status, 200); + assert.equal((await fetch(`${origin}/v1/hooks/supabase/sprout`)).status, 405); + assert.equal((await fetch(`${origin}/v1/hooks/supabase/sprout?token=fixture`)).status, 404); + const raw = '{ "space": "kept", "code": "fixture" }'; + const response = await fetch(`${origin}/v1/hooks/supabase/sprout`, { method: "POST", + headers: { "content-type": "application/json", "webhook-id": "fixture" }, body: raw }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(seen, [raw]); + const status = await new Promise((resolve, reject) => { + const req = nodeRequest(`${origin}/v1/hooks/supabase/sprout`, { method: "POST", + headers: ["content-type", "application/json", "webhook-id", "fixture", "webhook-id", "duplicate"] }, + res => { res.resume(); res.on("end", () => resolve(res.statusCode)); }); + req.on("error", reject); req.end(raw); + }); + assert.equal(status, 400); + assert.equal(seen.length, 1); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } +}); + +test("listener never exposes thrown secret-bearing errors", async () => { + const server = createAuthHookServer(async () => { throw new Error("fixture-secret"); }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/v1/hooks/supabase/sprout`, { method: "POST", body: "{}" }); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes("fixture-secret")); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } +}); diff --git a/apps/api/src/lib/auth-hook-http.ts b/apps/api/src/lib/auth-hook-http.ts new file mode 100644 index 00000000..f4d155f6 --- /dev/null +++ b/apps/api/src/lib/auth-hook-http.ts @@ -0,0 +1,50 @@ +import { createServer } from "node:http"; +import { Readable } from "node:stream"; + +/** Dedicated hook listener: no access/body logging or ordinary API middleware. */ +export function createAuthHookServer(hook: (request: Request) => Promise) { + const server = createServer(async (req, res) => { + res.setHeader("cache-control", "no-store"); + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }).end('{"status":"ok"}'); + return; + } + if (req.url !== "/v1/hooks/supabase/sprout") { res.writeHead(404).end(); return; } + if (req.method !== "POST") { res.writeHead(405, { allow: "POST" }).end(); return; } + const controller = new AbortController(); + const abort = () => controller.abort(); + const closed = () => { if (!res.writableEnded) abort(); }; + req.once("aborted", abort); + res.once("close", closed); + try { + const headers = new Headers(); + // Do not let Node merge duplicate signature headers into a different value. + const seen = new Set(); + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i].toLowerCase(); + if (!name.startsWith("webhook-") && name !== "content-type" && name !== "content-length") continue; + if (seen.has(name)) { res.writeHead(400).end(); return; } + seen.add(name); headers.set(name, req.rawHeaders[i + 1]); + } + const request = new Request("https://hook.invalid/v1/hooks/supabase/sprout", { + method: "POST", headers, signal: controller.signal, + body: Readable.toWeb(req) as ReadableStream, duplex: "half", + } as RequestInit); + const response = await hook(request); + if (controller.signal.aborted) return; + // The adapter only emits bounded, fixed JSON. Never reflect request headers. + res.writeHead(response.status, { "content-type": "application/json" }).end(await response.text()); + } catch { + if (!res.headersSent && !res.destroyed) res.writeHead(503).end('{"error":"authentication_email_unconfirmed"}'); + } finally { + req.off("aborted", abort); res.off("close", closed); + } + }); + server.headersTimeout = 5000; + server.requestTimeout = 5000; + server.timeout = 6000; + server.maxHeadersCount = 32; + server.maxConnections = 16; + server.on("clientError", (_error, socket) => socket.destroy()); + return server; +} diff --git a/apps/api/src/lib/serialize.ts b/apps/api/src/lib/serialize.ts index aece9bd7..36739de4 100644 --- a/apps/api/src/lib/serialize.ts +++ b/apps/api/src/lib/serialize.ts @@ -154,6 +154,7 @@ export function serializeMessage(m: Message, engagement?: MessageEngagement) { provider: m.provider, provider_message_id: m.providerMessageId, content_hash: m.contentHash, + ...(m.authExpiresAt ? { content_policy: "authentication-v1", workspace_id: m.workspaceId, expires_at: m.authExpiresAt } : {}), rendered_html: m.renderedHtml, rendered_text: m.renderedText, sandbox: m.sandbox, diff --git a/apps/api/src/lib/ses-events.ts b/apps/api/src/lib/ses-events.ts index 32b1776c..39ca5e33 100644 --- a/apps/api/src/lib/ses-events.ts +++ b/apps/api/src/lib/ses-events.ts @@ -323,6 +323,17 @@ export async function applySesNotification( .where(eq(messages.providerMessageId, providerMessageId)) .limit(1); if (!message) return "ignored"; + // Never store token-bearing click URLs, user agents or echoed SMTP diagnostics + // for auth mail, even if a misconfigured upstream sends those events anyway. + if (message.authExpiresAt) { + if (kind === "open" || kind === "click") return "ignored"; + n = { ...n, + complaint: n.complaint ? { ...n.complaint, complaintFeedbackType: undefined, complainedRecipients: [{ emailAddress: message.toEmail }] } : undefined, + bounce: n.bounce ? { ...n.bounce, bounceSubType: undefined, + bouncedRecipients: [{ emailAddress: message.toEmail }], + bounceType: n.bounce.bounceType === "Permanent" ? "Permanent" : "Transient" } : undefined, + }; + } const suppressionSource = scope.source ?? "ses"; if (kind === "delivery") { diff --git a/apps/api/src/plugins/errors.ts b/apps/api/src/plugins/errors.ts index 98ed2521..e5d9d489 100644 --- a/apps/api/src/plugins/errors.ts +++ b/apps/api/src/plugins/errors.ts @@ -1,9 +1,18 @@ import type { FastifyError, FastifyInstance } from "fastify"; import { ZodError } from "zod"; -import { AppError } from "@rootmail/core"; +import { AppError, AuthContentError } from "@rootmail/core"; export function registerErrorHandler(app: FastifyInstance): void { app.setErrorHandler((error: FastifyError, req, reply) => { + // Includes JSON parser / database / Redis errors, which can echo body values. + if (req.url.split("?")[0] === "/v1/auth-messages") { + const status = error instanceof AuthContentError ? (error.code === "auth_expired" ? 422 : 503) + : error instanceof AppError ? error.statusCode + : error.statusCode && error.statusCode < 500 ? error.statusCode : 503; + const code = error instanceof AuthContentError ? error.code : error instanceof AppError ? error.code : "auth_send_refused"; + void reply.status(status).send({ error: { type: code, message: "Authentication send was refused; no automatic retry after an ambiguous send." } }); + return; + } if (error instanceof AppError) { void reply.status(error.statusCode).send(error.toJSON()); return; diff --git a/apps/api/src/routes/auth-messages.test.ts b/apps/api/src/routes/auth-messages.test.ts new file mode 100644 index 00000000..78b33d30 --- /dev/null +++ b/apps/api/src/routes/auth-messages.test.ts @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import { createHmac, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { after, before, describe, it } from "node:test"; +import { and, eq, inArray } from "drizzle-orm"; +import { AUTH_SUBJECT, closeAuthContentVault, closeQueues, closeRedis, enqueueSendDeferred, env, getAuthContentVault, getSendQueue, hashApiKey } from "@rootmail/core"; +import { apiKeys, auditEntries, closeDb, db, messages, orgAddons, organizations, sql, subTenants, suppressions, threadMessages, threads, workspaces } from "@rootmail/db"; +import { buildServer } from "../server"; +import { processSend } from "../../../worker/src/pipeline"; +import { processAuthSend } from "../../../worker/src/auth-send"; +import { reconcileAuthMessages } from "../../../worker/src/auth-reconcile"; +import { createPostgresHookLedger, createSupabaseEmailHook } from "../../../../packages/supabase/src/index"; +import { applySesNotification } from "../lib/ses-events"; + +const suffix = `auth${Date.now()}`; +const org = `org_${suffix}`, ws = `ws_${suffix}`, tenantA = `tnt_a_${suffix}`, tenantB = `tnt_b_${suffix}`; +const key = `rm_live_${suffix}`, clientKey = `rm_live_client_${suffix}`; +const fixtureSecret = "fixture-only-code-654321"; +let app: Awaited>; +let n = 0; +const ids: string[] = []; +const payload = () => ({ policy: "authentication-v1", to: "recipient@example.test", from: `no-reply@${env.ROOTMAIL_DOMAIN}`, + html: `

${fixtureSecret}

`, text: fixtureSecret, + expires_at: new Date(Date.now() + 300_000).toISOString(), idempotency_key: `fixture_request_${suffix}_${n++}` }); +const headers = (tenant?: string, credential = key) => ({ authorization: `Bearer ${credential}`, ...(tenant ? { "x-rootmail-subtenant": tenant } : {}) }); +const post = (body = payload(), tenant?: string, credential = key) => app.inject({ method: "POST", url: "/v1/auth-messages", headers: headers(tenant, credential), payload: body }); +async function row(id: string) { return (await db.select().from(messages).where(eq(messages.id, id)))[0]; } +async function accepted(body = payload(), tenant?: string) { + const response = await post(body, tenant); + assert.equal(response.statusCode, 202, response.body); + ids.push(response.json().id); + return response.json().id as string; +} + +describe("auth message privacy and expiry (mock delivery only)", () => { + before(async () => { + assert.equal(env.MAIL_PROVIDER, "mock", "This suite must never use a live provider"); + env.AUTH_CONTENT_REDIS_URL = process.env.AUTH_CONTENT_REDIS_URL ?? "redis://127.0.0.1:6395"; + env.AUTH_CONTENT_KEY = Buffer.alloc(32, 19).toString("base64"); + env.AUTH_MESSAGES_ENABLED = true; + env.AUTH_MESSAGE_SCOPES = [null, tenantA, tenantB].map(subTenantId => ({ workspaceId: ws, subTenantId })); + await getAuthContentVault().assertEphemeral(); + await db.insert(organizations).values({ id: org, name: "Auth fixture", slug: suffix }); + await db.insert(orgAddons).values({ id: `adn_${suffix}`, organizationId: org, addonId: "subtenant_pack", quantity: 5 }); + await db.insert(workspaces).values({ id: ws, organizationId: org, name: "Auth fixture", slug: suffix, environment: "live" }); + for (const id of [tenantA, tenantB]) await db.insert(subTenants).values({ id, workspaceId: ws, name: id, + sendingDomain: `${id}.example.test`, status: "verified", verificationToken: "fixture", dkimSelector: "rm1", dkimPublicKey: "fixture", dkimPrivateKey: "fixture" }); + for (const [secret, tenant] of [[key, null], [clientKey, tenantA]] as const) await db.insert(apiKeys).values({ + id: `key_${secret}`, workspaceId: ws, subTenantId: tenant, name: "fixture", prefix: "rm_live", last4: "test", keyHash: hashApiKey(secret), mode: "live", + }); + app = await buildServer(); + }); + after(async () => { + for (const id of ids) { + const message = await row(id); + if (message?.authExpiresAt) await getAuthContentVault().discard({ ...message, authExpiresAt: message.authExpiresAt }); + const job = await getSendQueue().getJob(id); + await job?.remove(); + } + await app?.close(); + await db.delete(organizations).where(eq(organizations.id, org)); + await closeQueues(); await closeRedis(); await closeAuthContentVault(); await closeDb(); + }); + + it("is disabled by default and rejects invalid payloads without echoing content", async () => { + env.AUTH_MESSAGES_ENABLED = false; + assert.equal((await post()).statusCode, 503); + env.AUTH_MESSAGES_ENABLED = true; + for (const extra of [{ subject: fixtureSecret }, { metadata: { code: fixtureSecret } }, { send_at: new Date().toISOString() }, { attachments: [] }]) { + const r = await post({ ...payload(), ...extra }); + assert.equal(r.statusCode, 422); + assert.ok(!r.body.includes(fixtureSecret)); + } + const malformed = await app.inject({ method: "POST", url: "/v1/auth-messages", headers: { ...headers(), "content-type": "application/json" }, payload: `{"html":"${fixtureSecret}"` }); + assert.equal(malformed.statusCode, 400); + assert.ok(!malformed.body.includes(fixtureSecret)); + }); + + it("rejects expired and excessively long validity before creating any row", async () => { + for (const offset of [-1, 5000, 3_700_000]) { + const r = await post({ ...payload(), expires_at: new Date(Date.now() + offset).toISOString() }); + assert.equal(r.statusCode, 422); + assert.equal(r.json().error.type, "auth_expired"); + } + }); + + it("admits only exact pilot scopes and stops queued mail when a scope is removed", async () => { + const pending = await accepted(); + const scopes = env.AUTH_MESSAGE_SCOPES; + let sends = 0; + try { + for (const allowed of [[], [{ workspaceId: "ws_other", subTenantId: null }], + [{ workspaceId: ws, subTenantId: tenantA }]]) { + env.AUTH_MESSAGE_SCOPES = allowed; + assert.equal((await post()).statusCode, 503); + assert.equal((await app.inject({ method: "GET", url: "/v1/auth-messages/capabilities", headers: headers() })).statusCode, 503); + } + env.AUTH_MESSAGE_SCOPES = [{ workspaceId: ws, subTenantId: null }]; + assert.equal((await post(payload(), tenantA)).statusCode, 503); + env.AUTH_MESSAGE_SCOPES = []; + await processSend({ messageId: pending, workspaceId: ws }, async () => { + sends++; return { provider: "ses", providerMessageId: "must-not-send" }; + }); + assert.equal(sends, 0); + assert.equal((await row(pending)).error, "auth_unavailable"); + } finally { env.AUTH_MESSAGE_SCOPES = scopes; } + }); + + it("persists only non-secret metadata, queues only IDs, and makes no conversation copy", async () => { + const id = await accepted(); + const message = await row(id); + assert.equal(message.subject, AUTH_SUBJECT); + for (const field of ["renderedHtml", "renderedText", "contentHash"] as const) assert.equal(message[field], null); + assert.deepEqual(message.variables, {}); + assert.ok(!JSON.stringify(message).includes(fixtureSecret)); + const job = await getSendQueue().getJob(id); + assert.deepEqual(job?.data, { messageId: id, workspaceId: ws }); + assert.equal(job?.opts.attempts, 1); + assert.equal(job?.opts.priority, 1); + const copies = await db.select().from(threadMessages).where(eq(threadMessages.messageId, id)); + assert.equal(copies.length, 0); + const response = await app.inject({ method: "GET", url: `/v1/messages/${id}`, headers: headers() }); + assert.equal(response.json().content_policy, "authentication-v1"); + assert.ok(!response.body.includes(fixtureSecret)); + // Database enforcement protects other writers, not just this route. + await assert.rejects(db.update(messages).set({ renderedHtml: fixtureSecret }).where(eq(messages.id, id))); + }); + + it("deduplicates simultaneous admissions, rejects conflicts and isolates client namespaces", async () => { + const body = payload(); + const responses = await Promise.all([post(body, tenantA), post(body, tenantA)]); + assert.deepEqual(responses.map(r => r.statusCode).sort(), [200, 202]); + const id = responses[0].json().id; + ids.push(id); + assert.equal(id, responses[1].json().id); + assert.equal((await post({ ...body, text: "changed" }, tenantA)).statusCode, 409); + const second = await accepted(body, tenantB); + assert.notEqual(id, second); + const forbidden = await post(payload(), tenantB, clientKey); + assert.equal(forbidden.statusCode, 403); + const inaccessible = await app.inject({ method: "GET", url: `/v1/messages/${second}`, headers: headers(undefined, clientKey) }); + assert.equal(inaccessible.statusCode, 404); + const ordinaryReplay = await app.inject({ method: "POST", url: "/v1/messages", headers: headers(), + payload: { to: body.to, html: "ordinary", subject: "ordinary", idempotency_key: (await row(id)).idempotencyKey } }); + assert.equal(ordinaryReplay.statusCode, 409); + }); + + it("consumes content once, does not simulate delivery, and cannot be manually retried", async () => { + const id = await accepted(); + let sends = 0; + const fake = async (_message: unknown, content: { text: string }) => { + sends++; assert.equal(content.text, fixtureSecret); + return { provider: "ses", providerMessageId: `fake_${id}` }; + }; + await Promise.all([processSend({ messageId: id, workspaceId: ws }, fake), processSend({ messageId: id, workspaceId: ws }, fake)]); + assert.equal(sends, 1); + assert.equal((await row(id)).status, "sent"); // NOT delivered + await assert.rejects(getAuthContentVault().take({ ...(await row(id)), authExpiresAt: (await row(id)).authExpiresAt! })); + const retry = await app.inject({ method: "POST", url: `/v1/messages/${id}/retry`, headers: headers() }); + assert.equal(retry.statusCode, 400); + assert.equal((await db.select().from(threads).where(eq(threads.workspaceId, ws))).length, 0); + }); + + it("checks expiry at worker time and refuses provider failures without recording secrets", async () => { + const expired = await accepted(); + await db.update(messages).set({ authExpiresAt: new Date(Date.now() - 1) }).where(eq(messages.id, expired)); + let calls = 0; + const fake = async () => { calls++; throw new Error(fixtureSecret); }; + await processSend({ messageId: expired, workspaceId: ws }, fake); + assert.equal(calls, 0); + assert.equal((await row(expired)).error, "auth_expired"); + const failed = await accepted(); + await processSend({ messageId: failed, workspaceId: ws }, fake); + assert.equal(calls, 1); + assert.equal((await row(failed)).error, "auth_send_outcome_unknown"); + await processSend({ messageId: failed, workspaceId: ws }, fake); + assert.equal(calls, 1); + const evidence = await db.select().from(auditEntries).where(inArray(auditEntries.messageId, [expired, failed])); + assert.ok(!JSON.stringify(evidence).includes(fixtureSecret)); + assert.ok(!JSON.stringify(await row(failed)).includes(fixtureSecret)); + }); + + it("a stale worker cannot fail or purge another worker's claimed send", async () => { + const id = await accepted(); + const stale = await row(id); + let release!: () => void; + let entered!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const winner = processSend({ messageId: id, workspaceId: ws }, async () => { + entered(); await blocked; + return { provider: "ses", providerMessageId: `fake_${id}` }; + }); + try { + await started; + env.AUTH_MESSAGES_ENABLED = false; + await processAuthSend(stale, async () => { assert.fail("stale worker must not audit a failure"); }, async () => false); + assert.equal((await row(id)).status, "sending"); + } finally { env.AUTH_MESSAGES_ENABLED = true; release(); await winner; } + assert.equal((await row(id)).status, "sent"); + }); + + it("preserves auth priority and one attempt when deferring a queue job", async () => { + const messageId = `deferred_${suffix}`; + await enqueueSendDeferred({ messageId, workspaceId: ws }, 1000, { singleAttempt: true, priority: "high" }); + const jobs = (await getSendQueue().getJobs(["delayed", "prioritized", "waiting"])) + .filter(job => job.data.messageId === messageId); + try { + assert.equal(jobs.length, 1); + assert.equal(jobs[0].opts.attempts, 1); + assert.equal(jobs[0].opts.priority, 1); + assert.deepEqual(jobs[0].data, { messageId, workspaceId: ws }); + } finally { await Promise.all(jobs.map(job => job.remove())); } + }); + + it("ignores auth tracking events and scrubs echoed provider diagnostics", async () => { + const id = await accepted(); + await processSend({ messageId: id, workspaceId: ws }, async () => ({ provider: "ses", providerMessageId: `fake_${id}` })); + for (const eventType of ["Open", "Click"]) assert.equal(await applySesNotification({ eventType, mail: { messageId: `fake_${id}` }, + click: { link: `https://example.test?token=${fixtureSecret}` }, open: { userAgent: fixtureSecret } }), "ignored"); + await applySesNotification({ eventType: "Bounce", mail: { messageId: `fake_${id}` }, + bounce: { bounceType: "Permanent", bounceSubType: fixtureSecret, bouncedRecipients: [{ emailAddress: "recipient@example.test", diagnosticCode: fixtureSecret }] } }); + assert.ok(!JSON.stringify(await row(id)).includes(fixtureSecret)); + const evidence = await db.select().from(auditEntries).where(eq(auditEntries.messageId, id)); + assert.ok(!JSON.stringify(evidence).includes(fixtureSecret)); + }); + + it("rejects a revoked key before touching content storage", async () => { + await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.keyHash, hashApiKey(clientKey))); + try { assert.equal((await post(payload(), undefined, clientKey)).statusCode, 401); } + finally { await db.update(apiKeys).set({ revokedAt: null }).where(eq(apiKeys.keyHash, hashApiKey(clientKey))); } + }); + + it("blocks suppression added after enqueue, and never falls through to mock delivery", async () => { + const body = { ...payload(), to: "later-suppressed@example.test" }; + const id = await accepted(body); + await db.insert(suppressions).values({ id: `sup_${suffix}`, workspaceId: ws, email: body.to, reason: "bounce", source: "fixture" }); + let sends = 0; + await processSend({ messageId: id, workspaceId: ws }, async () => { sends++; return { provider: "ses", providerMessageId: "fake" }; }); + assert.equal(sends, 0); + assert.equal((await row(id)).status, "suppressed"); + assert.equal((await post({ ...payload(), to: body.to })).statusCode, 409); + const simulated = await accepted({ ...payload(), to: "mock-refused@example.test" }); + await processSend({ messageId: simulated, workspaceId: ws }); // MAIL_PROVIDER=mock: must NOT invoke MockProvider + assert.equal((await row(simulated)).status, "failed"); + assert.equal((await row(simulated)).provider, null); + }); + + it("honors the worker kill switch and does not route around a suspended org", async () => { + const disabled = await accepted({ ...payload(), to: "disabled@example.test" }); + let sends = 0; + const fake = async () => { sends++; return { provider: "ses", providerMessageId: "fake" }; }; + env.AUTH_MESSAGES_ENABLED = false; + try { await processSend({ messageId: disabled, workspaceId: ws }, fake); } + finally { env.AUTH_MESSAGES_ENABLED = true; } + assert.equal((await row(disabled)).error, "auth_unavailable"); + const suspended = await accepted({ ...payload(), to: "suspended@example.test" }); + await db.update(organizations).set({ sendingSuspended: true }).where(eq(organizations.id, org)); + try { await processSend({ messageId: suspended, workspaceId: ws }, fake); } + finally { await db.update(organizations).set({ sendingSuspended: false }).where(eq(organizations.id, org)); } + assert.equal((await row(suspended)).status, "failed"); + assert.equal(sends, 0); + }); + + it("capability preflight exposes authenticated scope and refuses mock delivery claims", async () => { + const response = await app.inject({ method: "GET", url: "/v1/auth-messages/capabilities", headers: headers(tenantA) }); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { policy: "authentication-v1", workspace_id: ws, sub_tenant_id: tenantA, provider: "mock" }); + env.AUTH_MESSAGES_ENABLED = false; + try { + assert.equal((await app.inject({ method: "GET", url: "/v1/auth-messages/capabilities", headers: headers() })).statusCode, 503); + } finally { env.AUTH_MESSAGES_ENABLED = true; } + }); + + it("Postgres ledger survives concurrent retries/restarts and the hook traverses the real API/worker with fake delivery", async () => { + // This is the customer's optional hook-host schema, installed ONLY in this local test DB. + await sql.unsafe(await readFile(new URL("../../../../packages/supabase/examples/ledger.sql", import.meta.url), "utf8")); + const eventKeys = new Set(); + const query = async (text: string, values: readonly string[]) => { + eventKeys.add(values[0]); + return sql.unsafe(text, [...values]); + }; + const ledger = createPostgresHookLedger(query); + try { + const eventKey = randomBytes(32).toString("hex"); + const fingerprint = randomBytes(32).toString("hex"); + const claims = await Promise.all(Array.from({ length: 8 }, (_, i) => ledger.claim({ eventKey, fingerprint, + expiresAt: new Date(Date.now() + 100_000 + i * 1000).toISOString() }))); + assert.ok(claims.every(row => row.expiresAt === claims[0].expiresAt)); + assert.deepEqual(await createPostgresHookLedger(query).claim({ eventKey, fingerprint: "f".repeat(64), + expiresAt: new Date(Date.now() + 200_000).toISOString() }), claims[0]); + const hookKey = Buffer.alloc(32, 13); + const config = { hookSecrets: [`whsec_${hookKey.toString("base64")}`], supabaseUrl: "https://project.example.test", + workspaceId: ws, clientId: null, from: `no-reply@${env.ROOTMAIL_DOMAIN}`, brand: "Fixture", + emailChangeMode: "secure" as const, allowedRedirects: ["fixtureapp://auth/callback"], defaultRedirect: "fixtureapp://auth/callback", + rootmailUrl: "https://api.example.test", rootmailApiKey: key, ledgerKey: Buffer.alloc(32, 14).toString("base64"), + credentialLifetimeSeconds: 600, ledger, budgetMs: 4000 }; + let calls = 0; + const bridge: typeof fetch = async (url, opts) => { + const target = new URL(String(url)); + const response = await app.inject({ method: opts?.method === "POST" ? "POST" : "GET", url: target.pathname, + headers: opts?.headers as Record, ...(opts?.body ? { payload: String(opts.body) } : {}) }); + const body = response.json(); + if (target.pathname.endsWith("capabilities") && response.statusCode === 200) { + body.provider = "ses"; // Synthetic capability ONLY: real local worker still uses injected fake delivery. + } + if (opts?.method === "POST" && response.statusCode < 300) { + ids.push(body.id); + await processSend({ messageId: body.id, workspaceId: ws }, async () => { + calls++; return { provider: "ses", providerMessageId: `fake_hook_${calls}` }; + }); + } + return Response.json(body, { status: response.statusCode }); + }; + const signed = (seconds: number) => { + const body = JSON.stringify({ user: { email: "hook-current@example.test", new_email: "hook-next@example.test" }, + email_data: { email_action_type: "email_change", token: "123456", token_hash: "fixture-hash", token_new: "654321", token_hash_new: "fixture-other-hash" } }); + const id = `hook_${suffix}`; + return new Request("https://hook.example.test", { method: "POST", headers: { "content-type": "application/json", + "webhook-id": id, "webhook-timestamp": String(seconds), + "webhook-signature": `v1,${createHmac("sha256", hookKey).update(`${id}.${seconds}.${body}`).digest("base64")}` }, body }); + }; + const now = Math.floor(Date.now() / 1000); + assert.equal((await createSupabaseEmailHook(config, { fetch: bridge })(signed(now - 10))).status, 200); + assert.equal(calls, 2); + assert.equal((await createSupabaseEmailHook({ ...config, ledger: createPostgresHookLedger(query) }, { fetch: bridge })(signed(now))).status, 200); + assert.equal(calls, 2); + const persisted = await sql`select * from rootmail_auth_hook.events where event_key in ${sql([...eventKeys])}`; + assert.ok(!JSON.stringify(persisted).includes("123456")); + assert.ok(!JSON.stringify(persisted).includes("@example.test")); + } finally { + if (eventKeys.size) await sql`delete from rootmail_auth_hook.events where event_key in ${sql([...eventKeys])}`; + } + }); + + it("reconciliation expires queued work, labels stale claims unknown, and never resends or rewrites sent evidence", async () => { + const queued = await accepted({ ...payload(), to: "reconcile-queued@example.test" }); + const claimed = await accepted({ ...payload(), to: "reconcile-sending@example.test" }); + const sent = await accepted({ ...payload(), to: "reconcile-sent@example.test" }); + await processSend({ messageId: sent, workspaceId: ws }, async () => ({ provider: "ses", providerMessageId: "fake_reconcile" })); + await db.update(messages).set({ authExpiresAt: new Date(Date.now() - 120_000) }).where(inArray(messages.id, [queued, claimed, sent])); + await db.update(messages).set({ status: "sending" }).where(eq(messages.id, claimed)); + await reconcileAuthMessages(); + assert.equal((await row(queued)).error, "auth_expired"); + assert.equal((await row(claimed)).error, "auth_send_outcome_unknown"); + assert.equal((await row(sent)).status, "sent"); + const audits = await db.select().from(auditEntries).where(and(eq(auditEntries.messageId, claimed), eq(auditEntries.event, "failed"))); + await reconcileAuthMessages(); + assert.equal(audits.length, 1); + assert.equal((await db.select().from(auditEntries).where(and(eq(auditEntries.messageId, claimed), eq(auditEntries.event, "failed")))).length, 1); + let attempts = 0; + await processSend({ messageId: claimed, workspaceId: ws }, async () => { attempts++; throw new Error("must not resend"); }); + assert.equal(attempts, 0); + }); +}); diff --git a/apps/api/src/routes/auth-messages.ts b/apps/api/src/routes/auth-messages.ts new file mode 100644 index 00000000..7c61edec --- /dev/null +++ b/apps/api/src/routes/auth-messages.ts @@ -0,0 +1,132 @@ +import { and, eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { AppError, AUTH_CONTENT_POLICY, AUTH_SUBJECT, assertAuthLifetime, authRequestFingerprint, + authScopeEnabled, checkAndCount, enqueueSend, env, Errors, getAuthContentVault, newId, ORG_SENDS_PER_MINUTE, + scanContent, sha256Hex, testRecipientFor } from "@rootmail/core"; +import { db, isPlatformBetaFrom, messages, organizations, orgSendingProviders, unverifiedSendRecipients } from "@rootmail/db"; +import { writeAudit } from "../lib/audit"; +import { assertEmailVerified, assertTransactionalSendCapacity, recordSend, recordTransactionalDaily, tryConsumeQuota } from "../lib/billing"; +import { requireFeature } from "../lib/features"; +import { requirePermission } from "../lib/permissions"; +import { isSuppressed } from "../lib/queries"; +import { serializeMessage } from "../lib/serialize"; +import { assertSenderAllowed } from "../lib/senders"; + +const bodySchema = z.object({ + policy: z.literal(AUTH_CONTENT_POLICY), + to: z.string().email().max(254), + from: z.string().email().max(254), + reply_to: z.string().email().max(254).optional(), + html: z.string().min(1).max(64_000), + text: z.string().max(32_000), + expires_at: z.string().datetime(), + idempotency_key: z.string().regex(/^[A-Za-z0-9_-]{16,160}$/), +}).strict(); + +/** Explicit versioned endpoint: old servers 404, never silently strip a privacy flag. */ +export async function authMessageRoutes(app: FastifyInstance): Promise { + app.get("/v1/auth-messages/capabilities", async (req, reply) => { + if (!authScopeEnabled(req.auth.workspace.id, req.auth.subTenant?.id ?? null)) throw new AppError(503, "auth_unavailable", "Authentication sending is unavailable"); + await requirePermission(req, "messages.send"); + if (!req.auth.apiKey || req.auth.mode !== "live") throw Errors.forbidden("Live server key required"); + return reply.header("cache-control", "no-store").send({ policy: AUTH_CONTENT_POLICY, + workspace_id: req.auth.workspace.id, sub_tenant_id: req.auth.subTenant?.id ?? null, provider: env.MAIL_PROVIDER }); + }); + app.post("/v1/auth-messages", { bodyLimit: 128 * 1024 }, async (req, reply) => { + if (!authScopeEnabled(req.auth.workspace.id, req.auth.subTenant?.id ?? null)) throw new AppError(503, "auth_unavailable", "Authentication sending is unavailable"); + await requirePermission(req, "messages.send"); + const { workspace, subTenant, apiKey, mode } = req.auth; + if (!apiKey || mode !== "live") throw Errors.forbidden("Authentication mail requires a live server API key"); + const parsed = bodySchema.safeParse(req.body); + if (!parsed.success) throw Errors.validation("Invalid authentication message"); // never echo rejected values/field names + const body = parsed.data; + if (body.reply_to?.toLowerCase().startsWith("reply+")) { + throw Errors.validation("Authentication mail must not use a conversation capture address"); + } + const authExpiresAt = new Date(body.expires_at); + assertAuthLifetime(authExpiresAt); + if (testRecipientFor(body.to)) throw Errors.validation("Authentication mail cannot use simulator recipients"); + if (subTenant) { + await requireFeature(req, "subtenants"); + if (subTenant.status !== "verified" || subTenant.reputationState === "paused") throw Errors.forbidden("Client cannot send"); + } + if (workspace.reputationState === "paused") throw Errors.forbidden("Workspace cannot send"); + + const [org] = await db.select().from(organizations).where(eq(organizations.id, workspace.organizationId)).limit(1); + if (!org || org.sendingSuspended) throw Errors.forbidden("Account cannot send"); + // First implementation only supports our SES path. Never switch a BYO customer onto it. + const [connection] = await db.select({ id: orgSendingProviders.id }).from(orgSendingProviders) + .where(eq(orgSendingProviders.organizationId, org.id)).limit(1); + if (connection || org.byoProvider || org.dedicatedIpStatus === "active") { + throw Errors.badRequest("Authentication sending is not supported for this provider configuration"); + } + await assertEmailVerified(org); + await assertSenderAllowed({ fromEmail: body.from, organizationId: org.id, subTenantDomain: subTenant?.sendingDomain }); + if (isPlatformBetaFrom(body.from) && (!body.reply_to || isPlatformBetaFrom(body.reply_to))) { + throw Errors.validation("Authentication mail from a beta address needs an explicit reply mailbox"); + } + if ((await unverifiedSendRecipients(workspace.id, [body.to])).length) throw Errors.validation("Recipient is not confirmed"); + if (scanContent({ html: body.html, text: body.text, attachments: [], blockedHosts: (env.BLOCKED_LINK_HOSTS ?? "").split(",").filter(Boolean) }).length) { + throw Errors.validation("Authentication content was refused"); + } + const subTenantId = subTenant?.id ?? null; + // Namespaced by authenticated scope; a client cannot collide with another client or ordinary mail. + const idempotencyKey = `auth_${sha256Hex(JSON.stringify([workspace.id, subTenantId, body.idempotency_key]))}`; + const fingerprint = authRequestFingerprint([workspace.id, subTenantId, body.to, body.from, body.reply_to ?? null, + body.html, body.text, authExpiresAt.toISOString()]); + const existingQuery = () => db.select().from(messages).where(and(eq(messages.workspaceId, workspace.id), eq(messages.idempotencyKey, idempotencyKey))).limit(1); + const replay = async (existing: typeof messages.$inferSelect) => { + if (existing.subTenantId !== subTenantId || existing.authRequestFingerprint !== fingerprint) { + throw Errors.conflict("Authentication idempotency key was reused with different input"); + } + if (existing.status === "queued") { + // Recover an enqueue failure without replacing content, extending expiry or charging again. + await enqueueSend({ messageId: existing.id, workspaceId: workspace.id }, { priority: "high", singleAttempt: true }); + } + return reply.header("Idempotent-Replayed", "true").status(200).send(serializeMessage(existing)); + }; + const [existing] = await existingQuery(); + if (existing) return replay(existing); + if (await isSuppressed(workspace.id, subTenantId, body.to, "transactional")) throw Errors.conflict("Recipient is suppressed"); + const burst = await checkAndCount(`send:${org.id}`, ORG_SENDS_PER_MINUTE, 60); + if (!burst.allowed) throw Errors.rateLimited("Authentication send rate exceeded"); + const vault = getAuthContentVault(); + await vault.assertEphemeral(); + const scope = { id: newId("message"), workspaceId: workspace.id, subTenantId, authExpiresAt }; + await vault.put(scope, { html: body.html, text: body.text }); + let charged = false; + let inserted = false; + try { + charged = await tryConsumeQuota(org); + if (!charged) { + await assertTransactionalSendCapacity(org, 1); + throw Errors.quotaExceeded(); // fail closed if capacity changed between checks + } + assertAuthLifetime(authExpiresAt); + const [message] = await db.insert(messages).values({ + ...scope, type: "transactional", toEmail: body.to, fromEmail: body.from, + replyTo: body.reply_to ?? null, subject: AUTH_SUBJECT, + priority: "high", idempotencyKey, authRequestFingerprint: fingerprint, status: "queued", sandbox: false, + }).onConflictDoNothing().returning(); + if (!message) { + const [winner] = await existingQuery(); + if (!winner) throw Errors.internal(); + return await replay(winner); + } + inserted = true; + await writeAudit(db, { workspaceId: workspace.id, subTenantId, messageId: message.id, + event: "queued", actor: "api_key", actorId: apiKey.id }); + await enqueueSend({ messageId: message.id, workspaceId: workspace.id }, { priority: "high", singleAttempt: true }); + return reply.status(202).send(serializeMessage(message)); + } finally { + if (!inserted) { + await vault.discard(scope); + if (charged) { + await recordSend(org.id, -1, "transactional"); + await recordTransactionalDaily(org.id, -1); + } + } + } + }); +} diff --git a/apps/api/src/routes/messages.ts b/apps/api/src/routes/messages.ts index 0cff7d88..40bb045e 100644 --- a/apps/api/src/routes/messages.ts +++ b/apps/api/src/routes/messages.ts @@ -276,6 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { ) .limit(1); if (existing) { + if (existing.authExpiresAt) throw Errors.conflict("Use the authentication endpoint for this request"); void reply.header("Idempotent-Replayed", "true"); return reply.status(200).send(serializeMessage(existing)); } @@ -573,6 +574,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { await recordTransactionalDaily(org.id, -1); // the day counter too } } + if (existing.authExpiresAt) throw Errors.conflict("Use the authentication endpoint for this request"); void reply.header("Idempotent-Replayed", "true"); return reply.status(200).send(serializeMessage(existing)); } @@ -711,6 +713,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { await requirePermission(req, "messages.send"); const { id } = req.params as { id: string }; const message = await getScopedMessage(req, id); + if (message.authExpiresAt) throw Errors.badRequest("Authentication messages cannot be resent. Request a new code from the authentication provider."); const verdict = canRetryMessage({ status: message.status, @@ -791,6 +794,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { const bundle = { message_id: message.id, + ...(message.authExpiresAt ? { content_policy: "authentication-v1", content_retained: false } : {}), content_hash: message.contentHash, subject: message.subject, to: message.toEmail, diff --git a/apps/api/src/routes/system-mail-preview.test.ts b/apps/api/src/routes/system-mail-preview.test.ts index b51868f2..deca4795 100644 --- a/apps/api/src/routes/system-mail-preview.test.ts +++ b/apps/api/src/routes/system-mail-preview.test.ts @@ -38,6 +38,8 @@ import { processSystemMail } from "../../../worker/src/system-mail"; // Staff-only preview of marketing-class system mail. The API only ENQUEUES // (MAIL_PROVIDER=mock, no worker in tests); the header test then runs the // enqueued job through the real worker path with every AWS call intercepted. +// API test files run sequentially: this suite resets rootmail-hq's shared daily +// counter while other suites send platform mail. In-test concurrency is retained. const stamp = Date.now(); const staffEmail = `preview-staff-${stamp}@example.test`; const supportEmail = `preview-support-${stamp}@example.test`; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a82cd32d..767073b5 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,7 +2,8 @@ import multipart from "@fastify/multipart"; import rateLimit from "@fastify/rate-limit"; import sensible from "@fastify/sensible"; import Fastify, { type FastifyInstance } from "fastify"; -import { assertProofSigningKey, createRedis, env } from "@rootmail/core"; +import { assertProofSigningKey, closeAuthContentVault, createRedis, env } from "@rootmail/core"; +import { authMessageRoutes } from "./routes/auth-messages"; import "./context"; import { registerAuth } from "./plugins/auth"; import { registerErrorHandler } from "./plugins/errors"; @@ -74,6 +75,7 @@ export async function buildServer(): Promise { // must never reach a log even if someone adds body logging later. redact: { paths: [ + "req.body", "body", // auth codes must never enter request-body logs "req.body.secret_access_key", "req.body.api_key", "req.body.webhook_signing_key", @@ -100,6 +102,7 @@ export async function buildServer(): Promise { // a failing one. const rateLimitRedis = createRedis(); app.addHook("onClose", async () => { + await closeAuthContentVault(); await rateLimitRedis.quit().catch(() => undefined); }); @@ -135,6 +138,7 @@ export async function buildServer(): Promise { await app.register(scimRoutes); await app.register(senderRoutes); await app.register(messageRoutes); + await app.register(authMessageRoutes); await app.register(subTenantRoutes); await app.register(contactRoutes); await app.register(subscribeRoutes); diff --git a/apps/worker/src/auth-reconcile.ts b/apps/worker/src/auth-reconcile.ts new file mode 100644 index 00000000..bbe48288 --- /dev/null +++ b/apps/worker/src/auth-reconcile.ts @@ -0,0 +1,32 @@ +import { and, eq, inArray, isNotNull, lte } from "drizzle-orm"; +import { env, getAuthContentVault, newId } from "@rootmail/core"; +import { auditEntries, db, messages } from "@rootmail/db"; + +/** Classify expired orphaned claims; NEVER requeue or resend a credential. */ +export async function reconcileAuthMessages(now = new Date()): Promise { + if (!env.AUTH_MESSAGES_ENABLED) return 0; + // The provider call is bounded to 5s; wait an extra minute beyond the credential + // deadline before classifying an interrupted send as unknown, not undelivered. + const cutoff = new Date(now.getTime() - 60_000); + const candidates = await db.select().from(messages).where(and( + isNotNull(messages.authExpiresAt), lte(messages.authExpiresAt, cutoff), inArray(messages.status, ["queued", "sending"]), + )).orderBy(messages.authExpiresAt).limit(100); + let changed = 0; + for (const row of candidates) { + const reason = row.status === "sending" ? "auth_send_outcome_unknown" : "auth_expired"; + const updated = await db.transaction(async tx => { + const [result] = await tx.update(messages).set({ status: "failed", error: reason, updatedAt: now }) + .where(and(eq(messages.id, row.id), eq(messages.status, row.status), lte(messages.authExpiresAt, cutoff))) + .returning({ id: messages.id }); + if (!result) return false; + await tx.insert(auditEntries).values({ id: newId("audit"), workspaceId: row.workspaceId, subTenantId: row.subTenantId, + messageId: row.id, event: "failed", actor: "system", metadata: { reason, reconciled: true } }); + return true; + }); + if (!updated) continue; + changed++; + try { await getAuthContentVault().discard({ ...row, authExpiresAt: row.authExpiresAt! }); } + catch { /* Absolute TTL is still authoritative; never print cache diagnostics. */ } + } + return changed; +} diff --git a/apps/worker/src/auth-send.ts b/apps/worker/src/auth-send.ts new file mode 100644 index 00000000..aedd9a35 --- /dev/null +++ b/apps/worker/src/auth-send.ts @@ -0,0 +1,64 @@ +import { and, eq } from "drizzle-orm"; +import { AuthContentError, assertAuthLifetime, authScopeEnabled, getAuthContentVault, type AuthContent } from "@rootmail/core"; +import { db, messages, organizations, orgSendingProviders, workspaces, type Message } from "@rootmail/db"; +import { sendAuthViaSes } from "./providers/auth-ses"; + +export type AuthSend = (message: Message, content: AuthContent) => Promise<{ provider: string; providerMessageId: string }>; +type Audit = (message: Message, event: "sending" | "sent" | "failed" | "suppressed", extra?: { + provider?: string; providerMessageId?: string; metadata?: Record; +}) => Promise; + +/** Called only after the normal recipient/reputation/suspension checks. Never threads or renders. */ +export async function processAuthSend(message: Message, audit: Audit, suppressed: () => Promise, send: AuthSend = sendAuthViaSes): Promise { + if (!message.authExpiresAt) throw new Error("not_auth_message"); + const scope = { ...message, authExpiresAt: message.authExpiresAt }; + let vault: ReturnType | undefined; + let sentRecorded = false; + let ownsClaim = false; + try { + if (!authScopeEnabled(message.workspaceId, message.subTenantId) || message.sandbox) throw new AuthContentError("auth_unavailable"); + assertAuthLifetime(message.authExpiresAt); + vault = getAuthContentVault(); + const [org] = await db.select({ id: organizations.id, byo: organizations.byoProvider, dedicated: organizations.dedicatedIpStatus }) + .from(organizations).innerJoin(workspaces, eq(workspaces.organizationId, organizations.id)) + .where(eq(workspaces.id, message.workspaceId)).limit(1); + if (!org || org.byo || org.dedicated === "active") throw new AuthContentError("auth_unavailable"); + const [own] = await db.select({ id: orgSendingProviders.id }).from(orgSendingProviders) + .where(eq(orgSendingProviders.organizationId, org.id)).limit(1); + if (own) throw new AuthContentError("auth_unavailable"); + // A durable CAS, not just a BullMQ job ID. Concurrent/stalled workers cannot resend. + const [claimed] = await db.update(messages).set({ status: "sending", updatedAt: new Date() }) + .where(and(eq(messages.id, message.id), eq(messages.status, "queued"))).returning({ id: messages.id }); + if (!claimed) return; + ownsClaim = true; + await audit(message, "sending"); + if (await suppressed()) { + await db.update(messages).set({ status: "suppressed", error: "auth_recipient_suppressed", updatedAt: new Date() }).where(eq(messages.id, message.id)); + await audit(message, "suppressed"); + await vault.discard(scope); + return; + } + // Atomic GETDEL: no second fetch/retry after the provider might have accepted. + const content = await vault.take(scope); + assertAuthLifetime(message.authExpiresAt); + const result = await send(message, content); + if (result.provider !== "ses" || !result.providerMessageId) throw new Error("auth_simulation_refused"); + await db.update(messages).set({ status: "sent", provider: result.provider, providerMessageId: result.providerMessageId, + error: null, updatedAt: new Date() }).where(eq(messages.id, message.id)); + sentRecorded = true; + await audit(message, "sent", result); + } catch (error) { + // A later audit failure cannot undo an observed, durably recorded provider acceptance. + if (sentRecorded) return; + const reason = error instanceof AuthContentError ? error.code : "auth_send_outcome_unknown"; + const [failed] = await db.update(messages).set({ status: "failed", error: reason, updatedAt: new Date() }) + .where(and(eq(messages.id, message.id), eq(messages.status, ownsClaim ? "sending" : "queued"))) + .returning({ id: messages.id }); + // A stale worker must neither replace another worker's outcome nor purge its content. + if (!failed) return; + await audit(message, "failed", { metadata: { reason } }); + if (vault) await vault.discard(scope); + // Deliberately no rethrow: raw errors and secret-bearing provider requests + // must not land in BullMQ failedReason/stacktrace, logs, or automatic retries. + } +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 3f89328e..762541c3 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -24,6 +24,8 @@ import { betaReplyCaptureAvailable, closeDb } from "@rootmail/db"; import { processCampaignSend } from "./campaigns"; import { processLifecycleSweep } from "./lifecycle"; import { processSend } from "./pipeline"; +import { closeAuthContentVault } from "@rootmail/core"; +import { reconcileAuthMessages } from "./auth-reconcile"; import { forgetOrgProvider } from "./providers/for-org"; import { processDnsDriftSweep } from "./dns-drift"; import { processReputationSweep } from "./reputation"; @@ -61,7 +63,7 @@ worker.on("ready", () => { ); } }); -worker.on("completed", (job) => console.log(`✓ sent ${job.data.messageId} (job ${job.id})`)); +worker.on("completed", (job) => console.log(`✓ processed send job ${job.data.messageId} (job ${job.id})`)); worker.on("failed", (job, err) => console.error(`✗ failed ${job?.data.messageId} (job ${job?.id}): ${err.message}`), ); @@ -83,7 +85,10 @@ webhookWorker.on("error", (err) => console.error("webhook worker error:", err.me const sequenceWorker = new Worker( SEQUENCE_QUEUE, async (job) => { - if (job.name === "tick") await processSequenceTick(); + if (job.name === "tick") { + await reconcileAuthMessages().catch(() => console.error("[auth] reconciliation unavailable")); + await processSequenceTick(); + } }, { connection: createRedis() as unknown as ConnectionOptions, prefix: BULL_PREFIX, concurrency: 1 }, ); @@ -189,6 +194,7 @@ const shutdown = async (signal: string) => { await lifecycleWorker.close(); await reputationWorker.close(); providerChanges.disconnect(); + await closeAuthContentVault(); await closeDb(); process.exit(0); }; diff --git a/apps/worker/src/pipeline.ts b/apps/worker/src/pipeline.ts index ee5d3c41..450d7a86 100644 --- a/apps/worker/src/pipeline.ts +++ b/apps/worker/src/pipeline.ts @@ -26,6 +26,8 @@ import { ByoProviderUnavailable, resolveProviderForMessage, type ResolvedProvide import { addSesNoTrack, isInternalWorkspace, isPlatformMessage, platformConfigurationSet } from "./platform-mail"; import { unverifiedSendRecipients, RECIPIENT_VERIFICATION_REQUIRED } from "@rootmail/db"; import type { OutboundAttachment } from "./providers/types"; +import { processAuthSend, type AuthSend } from "./auth-send"; +import { AUTH_SEND_MARGIN_MS, assertAuthLifetime, getAuthContentVault } from "@rootmail/core"; /** Fetch each attachment's bytes from its public asset URL (host-independent). */ /** @@ -134,6 +136,14 @@ const DNS_SUSPENDED_ERROR = // rates that are fine. type ReputationGate = "ok" | "deferred" | "paused" | "dns_suspended" | "suspended"; +/** Auth gate failures may only transition queued rows, never another worker's claim. */ +async function failBeforeSend(message: Message, reason: string): Promise { + const [updated] = await db.update(messages).set({ status: "failed", error: reason, updatedAt: new Date() }) + .where(and(eq(messages.id, message.id), message.authExpiresAt ? eq(messages.status, "queued") : undefined)) + .returning({ id: messages.id }); + return Boolean(updated); +} + /** * Apply the tenant's current reputation state to one send. * @@ -201,17 +211,16 @@ async function reputationGate(message: Message, data: SendJobData): Promise= message.authExpiresAt.getTime()) { + if (!await failBeforeSend(message, "auth_expired")) return "deferred"; + await audit(message, "failed", { metadata: { reason: "auth_expired" } }); + try { await getAuthContentVault().discard({ ...message, authExpiresAt: message.authExpiresAt }); } catch { /* TTL remains. */ } + return "deferred"; + } if (deferrals > REPUTATION_MAX_DEFERRALS) { // Past a day of trying, a queue is just a place messages go to be forgotten. // Fail it honestly so it shows up as failed rather than eternally pending. - await db - .update(messages) - .set({ - status: "failed", - error: `Held by a send throttle for over ${REPUTATION_MAX_DEFERRALS} hours.`, - updatedAt: new Date(), - }) - .where(eq(messages.id, message.id)); + if (!await failBeforeSend(message, `Held by a send throttle for over ${REPUTATION_MAX_DEFERRALS} hours.`)) return "deferred"; await audit(message, "failed", { metadata: { reason: "throttle deferral limit reached", deferrals }, }); @@ -221,12 +230,13 @@ async function reputationGate(message: Message, data: SendJobData): Promise { +export async function processSend(data: SendJobData, authSend?: AuthSend): Promise { const [message] = await db.select().from(messages).where(eq(messages.id, data.messageId)).limit(1); if (!message) { console.warn(`[send] message ${data.messageId} not found — skipping`); @@ -236,13 +246,22 @@ export async function processSend(data: SendJobData): Promise { if (message.status !== "queued" && message.status !== "sending") { return; } + if (message.authExpiresAt) { + if (message.status !== "queued") return; + try { assertAuthLifetime(message.authExpiresAt); } + catch { + if (!await failBeforeSend(message, "auth_expired")) return; + await audit(message, "failed", { metadata: { reason: "auth_expired" } }); + try { await getAuthContentVault().discard({ ...message, authExpiresAt: message.authExpiresAt }); } catch { /* TTL remains. */ } + return; + } + } // Campaigns, sequences and scheduled mail must observe the same restriction // as the composer, including recipients removed after the mail was queued. if (!message.sandbox && (await unverifiedSendRecipients(message.workspaceId, [message.toEmail])).length) { const reason = `${message.toEmail} is not confirmed. ${RECIPIENT_VERIFICATION_REQUIRED}`; - await db.update(messages).set({ status: "failed", error: reason, updatedAt: new Date() }) - .where(eq(messages.id, message.id)); + if (!await failBeforeSend(message, reason)) return; await audit(message, "failed", { metadata: { reason } }); return; } @@ -262,14 +281,16 @@ export async function processSend(data: SendJobData): Promise { if (gate === "paused" || gate === "dns_suspended" || gate === "suspended") { const reason = gate === "paused" ? PAUSED_ERROR : gate === "suspended" ? SUSPENDED_ERROR : DNS_SUSPENDED_ERROR; - await db - .update(messages) - .set({ status: "failed", error: reason, updatedAt: new Date() }) - .where(eq(messages.id, message.id)); + if (!await failBeforeSend(message, reason)) return; await audit(message, "failed", { metadata: { reason } }); return; } + if (message.authExpiresAt) { + await processAuthSend(message, audit, () => isSuppressedAtSend(message), authSend); + return; + } + await db .update(messages) .set({ status: "sending", updatedAt: new Date() }) diff --git a/apps/worker/src/providers/auth-ses.test.ts b/apps/worker/src/providers/auth-ses.test.ts new file mode 100644 index 00000000..d7361e3b --- /dev/null +++ b/apps/worker/src/providers/auth-ses.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { authTrackingSafe, sendAuthWithTransport, type AuthSesTransport } from "./auth-ses"; +import type { Message } from "@rootmail/db"; + +test("auth SES config must disable opens/clicks while retaining delivery/bounce/complaint", () => { + const safe = { Enabled: true, MatchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT"] }; + assert.equal(authTrackingSafe([safe]), true); + assert.equal(authTrackingSafe(undefined), false); + assert.equal(authTrackingSafe([]), false); + assert.equal(authTrackingSafe([{ ...safe, Enabled: false }]), false); + assert.equal(authTrackingSafe([{ Enabled: true, MatchingEventTypes: ["DELIVERY"] }]), false); + for (const event of ["OPEN", "CLICK"]) { + assert.equal(authTrackingSafe([safe, { Enabled: true, MatchingEventTypes: [event] }]), false); + assert.equal(authTrackingSafe([safe, { MatchingEventTypes: [event] }]), false); + assert.equal(authTrackingSafe([safe, { Enabled: false, MatchingEventTypes: [event] }]), true); + } +}); + +const message = () => ({ authExpiresAt: new Date(Date.now() + 60_000), fromEmail: "auth@example.test", toEmail: "recipient@example.test", replyTo: null }) as Message; +const content = { html: 'Confirm', text: "fixture-secret" }; +const safeEvents = [{ Enabled: true, MatchingEventTypes: ["DELIVERY", "BOUNCE", "COMPLAINT"] }]; + +test("auth sender uses only the validated config set, generic subject and no-track links", async () => { + let sends = 0; + const transport: AuthSesTransport = { + settings: async (set, signal) => { assert.equal(set, "auth-only"); assert.equal(signal.aborted, false); return safeEvents; }, + send: async (input, signal) => { + sends++; + assert.equal(signal.aborted, false); + assert.equal(input.ConfigurationSetName, "auth-only"); + assert.equal(input.Content?.Simple?.Subject?.Data, "Authentication email"); + assert.ok(input.Content?.Simple?.Body?.Html?.Data?.includes("ses:no-track")); + assert.equal(input.Content?.Simple?.Headers, undefined); + return "fixture-provider-id"; + }, + }; + assert.deepEqual(await sendAuthWithTransport(message(), content, "auth-only", transport), { provider: "ses", providerMessageId: "fixture-provider-id" }); + assert.equal(sends, 1); +}); + +test("unsafe settings, expired-after-lookup and lookup failures never invoke send", async () => { + for (const mode of ["tracking", "expired", "lookup-error"]) { + const m = message(); + let sends = 0; + await assert.rejects(sendAuthWithTransport(m, content, "auth-only", { + settings: async () => { + if (mode === "lookup-error") throw new Error(content.text); + if (mode === "expired") m.authExpiresAt = new Date(Date.now() - 1); + return mode === "tracking" ? [...safeEvents, { Enabled: true, MatchingEventTypes: ["OPEN"] }] : safeEvents; + }, + send: async () => { sends++; return "fixture-id"; }, + }), { message: "auth_provider_outcome_unknown" }); + assert.equal(sends, 0); + } +}); + +test("provider errors and missing IDs are redacted and never retried", async () => { + for (const mode of ["failure", "missing-id"]) { + let sends = 0; + await assert.rejects(sendAuthWithTransport(message(), content, "auth-only", { + settings: async () => safeEvents, + send: async () => { sends++; if (mode === "failure") throw new Error(content.text); return undefined; }, + }), { message: "auth_provider_outcome_unknown" }); + assert.equal(sends, 1); + } +}); diff --git a/apps/worker/src/providers/auth-ses.ts b/apps/worker/src/providers/auth-ses.ts new file mode 100644 index 00000000..bb6849ad --- /dev/null +++ b/apps/worker/src/providers/auth-ses.ts @@ -0,0 +1,65 @@ +import { GetConfigurationSetEventDestinationsCommand, SendEmailCommand, SESv2Client, type SendEmailCommandInput } from "@aws-sdk/client-sesv2"; +import { AUTH_SUBJECT, assertAuthLifetime, env, type AuthContent } from "@rootmail/core"; +import type { Message } from "@rootmail/db"; +import { addSesNoTrack } from "../platform-mail"; + +type EventDestination = { Enabled?: boolean; MatchingEventTypes?: string[] }; +export interface AuthSesTransport { + settings: (configurationSet: string, signal: AbortSignal) => Promise; + send: (input: SendEmailCommandInput, signal: AbortSignal) => Promise; +} +export function authTrackingSafe(destinations: EventDestination[] | undefined): boolean { + const active = destinations?.filter(d => d.Enabled) ?? []; + const events = active.flatMap(d => d.MatchingEventTypes ?? []); + // An omitted Enabled is not evidence that engagement collection is disabled. + const unsafe = destinations?.some(d => d.Enabled !== false && d.MatchingEventTypes?.some(e => e === "OPEN" || e === "CLICK")); + return !unsafe && + ["DELIVERY", "BOUNCE", "COMPLAINT"].every(e => events.includes(e)); +} + +// Separate from the ordinary provider: one SDK attempt, no mock file sink, +// no BYO fallback, no shared/dedicated tracking set and no custom secret headers. +export async function sendAuthViaSes(message: Message, content: AuthContent): Promise<{ provider: string; providerMessageId: string }> { + if (!env.AUTH_MESSAGES_ENABLED || env.MAIL_PROVIDER !== "ses" || !env.AUTH_SES_CONFIGURATION_SET || !message.authExpiresAt) { + throw new Error("auth_provider_unavailable"); + } + const client = new SESv2Client({ region: env.AWS_REGION ?? "us-east-1", maxAttempts: 1 }); + try { + return await sendAuthWithTransport(message, content, env.AUTH_SES_CONFIGURATION_SET, { + settings: async (configurationSet, abortSignal) => (await client.send(new GetConfigurationSetEventDestinationsCommand({ ConfigurationSetName: configurationSet }), { abortSignal })).EventDestinations, + send: async (input, abortSignal) => (await client.send(new SendEmailCommand(input), { abortSignal })).MessageId, + }); + } finally { client.destroy(); } +} + +/** Injected transport is for local acceptance; the production wrapper above uses SES once. */ +export async function sendAuthWithTransport(message: Message, content: AuthContent, configurationSet: string, transport: AuthSesTransport) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + if (!message.authExpiresAt || !configurationSet) throw new Error("auth_provider_unavailable"); + assertAuthLifetime(message.authExpiresAt); + const settings = await transport.settings(configurationSet, controller.signal); + if (!authTrackingSafe(settings)) throw new Error("auth_tracking_unsafe"); + // Configuration verification can take time. Recheck immediately before provider I/O. + assertAuthLifetime(message.authExpiresAt); + if (controller.signal.aborted) throw new Error("auth_provider_timeout"); + const messageId = await transport.send({ + FromEmailAddress: message.fromEmail, + Destination: { ToAddresses: [message.toEmail] }, + ReplyToAddresses: message.replyTo ? [message.replyTo] : undefined, + ConfigurationSetName: configurationSet, + Content: { Simple: { + Subject: { Data: AUTH_SUBJECT, Charset: "UTF-8" }, + Body: { Html: { Data: addSesNoTrack(content.html), Charset: "UTF-8" }, Text: { Data: content.text, Charset: "UTF-8" } }, + } }, + }, controller.signal); + if (!messageId) throw new Error("auth_provider_unknown"); + return { provider: "ses", providerMessageId: messageId }; + } catch { + // Neither BullMQ nor database diagnostics may receive an SDK error/request. + throw new Error("auth_provider_outcome_unknown"); + } finally { + clearTimeout(timer); + } +} diff --git a/docs/COLLAB.md b/docs/COLLAB.md index 966289c9..400e4a29 100644 --- a/docs/COLLAB.md +++ b/docs/COLLAB.md @@ -303,3 +303,66 @@ services at 22:58–22:59 WAT, 15 October. Exact-main CI, both canary deliveries and a correctly captured post-release reply passed. Rollback images are retained; the temporary QA key is revoked and rejected (401). Evidence and limitations: `docs/beta-readiness-2026-10-15.md`. + +## 2026-10-24 — Supabase auth integration started, not released + +A private adapter foundation now verifies signed Supabase email hooks and prepares +the supported auth action/recipient plans, including secure email-change mappings. +It has no send transport and is not wired into any running service. Do not market +Rootmail as a shipped Supabase auth integration yet. Ordinary mail retention and +conversation copies require a separate auth-content policy before live codes can +flow through Rootmail. Tracking, expiry, durable replay handling and the authorized +Sprout native sign-in pilot remain release gates. See +`docs/supabase-auth-integration.md` and the package README. + +Configs touched: private package/TypeScript configuration and workspace lockfile. +Prod behaviour preserved: no runtime, provider, tracking or deployment changes. + +## 2026-10-25 — auth-content safeguards implemented behind an off switch + +The draft Supabase integration branch now has a dedicated authentication-send +endpoint and worker path. Encrypted short-lived content stays outside PostgreSQL +and conversations; the database enforces empty auth bodies/proof digests. Jobs carry +IDs only, expire conservatively, and cannot resend consumed content. Client-scoped +idempotency, safe errors and an SES-only no-engagement configuration check are +implemented and exercised with fake delivery. This is not a production launch: +the feature defaults off, the Supabase planner has no HTTP transport yet, stale +worker claims still need reconciliation, and the Sprout sign-in pilot is unrun. +Do not promise exactly-once delivery or claim that queue acceptance means delivery. + +Configs touched: disabled auth env settings/template, migration 0083 and journal, +API test-file sequencing and CI local ephemeral vault. Prod behaviour preserved: no deployment, production +migration, provider change or global tracking change. Existing ordinary mail stays +on its current path. + +## 2026-10-25 — Supabase hook transport and replay recovery (unreleased) + +PR #59 now includes an opt-in signed HTTP hook, capability/scope preflight, +bounded request/response work, PostgreSQL replay metadata and partial-recipient +recovery. A hook success requires recorded SES acceptance for every recipient; +queueing alone is not success and acceptance is not inbox delivery. The ledger +never renews expiry on a re-signed retry. Interrupted worker claims are classified +without resending. Local acceptance used the real API/database/queue and fake mail. + +There is a customer setup reference, not a completed customer installation. +Actual Supabase expiry/resend behavior, production privacy/configuration and +latency, a native Sprout session and a second-customer walkthrough are still +unverified. Do not market a shipped integration or exactly-once delivery. + +Configs touched: package metadata, reference customer ledger/setup and migration +0084's partial index. Prod behaviour preserved: no hook mounted, feature off, +no production migration, deployment, provider setting or real email. + +## 2026-10-25 — Sprout production preflight (unreleased) + +Live checks confirm a healthy existing host and sandboxed SES, but the auth +schema/configuration is absent and the worker cannot read SES event settings. +The narrow read policy is prepared, not applied. Sprout's US project has no +email hook/custom SMTP; its OTP is eight digits with a one-hour expiry. Native +sign-in remains unobserved. See `docs/sprout-pilot-validation-2026-10-25.md`. + +PR #59 now limits auth admission and queued sends to explicit workspace/client +pairs. Native consumers can select code-only email without browser redirects; +default link mode preserves its exact callback checks. The local test command +pins mock mail/DNS and preserves fixture environment settings. These controls +are tested implementation, not evidence that Sprout sign-in is live. diff --git a/docs/ops/auth-ses-read-policy.json b/docs/ops/auth-ses-read-policy.json new file mode 100644 index 00000000..70a55a01 --- /dev/null +++ b/docs/ops/auth-ses-read-policy.json @@ -0,0 +1,9 @@ +{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "ReadAuthDeliveryTrackingConfiguration", + "Effect": "Allow", + "Action": "ses:GetConfigurationSetEventDestinations", + "Resource": "arn:aws:ses:us-east-1:130299713609:configuration-set/rootmail-platform" + }] +} diff --git a/docs/ops/sprout-pilot.compose.yml b/docs/ops/sprout-pilot.compose.yml new file mode 100644 index 00000000..41ff8d3a --- /dev/null +++ b/docs/ops/sprout-pilot.compose.yml @@ -0,0 +1,66 @@ +# Optional overlay, copied to the host root only after review. Never include the +# API's broad env file on the hook. Requires explicit --profile auth-pilot. +# Use with the normal prod + host overlays and --env-file .env.prod. +services: + api: + networks: [default, auth-content] + worker: + networks: [default, auth-content] + auth-content-vault: + profiles: [auth-pilot] + image: valkey/valkey@sha256:9acdf6f0ae1771ea63c401e127054b2d1779227b9230dcfae37fa684610eaa4f + user: "999:1000" + command: [valkey-server, --save, "", --appendonly, "no", --maxmemory, 64mb, --maxmemory-policy, noeviction, --aclfile, /run/users.acl, --loglevel, warning] + restart: unless-stopped + read_only: true + mem_limit: 96m + memswap_limit: 96m + cpus: 0.25 + pids_limit: 32 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + ulimits: + core: {soft: 0, hard: 0} + tmpfs: ["/data:size=1m,mode=0700,uid=999,gid=1000", "/tmp:size=1m"] + volumes: + - /etc/rootmail/auth-vault.acl:/run/users.acl:ro + networks: [auth-content] + logging: + driver: json-file + options: {max-size: 1m, max-file: "2"} + sprout-hook: + profiles: [auth-pilot] + image: ${REGISTRY:-pachal}/rootmail-api:${TAG:?Use an immutable release tag} + working_dir: /app/apps/api + command: [node, --import, tsx, src/cli/supabase-hook-server.ts] + env_file: [.env.sprout-hook.prod] + environment: + NODE_ENV: production + HOOK_DATABASE_CA_FILE: /run/rds-ca.pem + ports: ["127.0.0.1:4011:4011"] + volumes: + - /etc/rootmail/rds-ca.pem:/run/rds-ca.pem:ro + networks: [default] + restart: unless-stopped + init: true + read_only: true + mem_limit: 192m + memswap_limit: 192m + cpus: 0.5 + pids_limit: 48 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + ulimits: + core: {soft: 0, hard: 0} + tmpfs: [/tmp:size=8m] + healthcheck: + test: [CMD, node, -e, "fetch('http://127.0.0.1:4011/health',{signal:AbortSignal.timeout(1000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 2s + retries: 3 + logging: + driver: json-file + options: {max-size: 1m, max-file: "2"} +networks: + auth-content: + internal: true diff --git a/docs/sprout-pilot-validation-2026-10-25.md b/docs/sprout-pilot-validation-2026-10-25.md new file mode 100644 index 00000000..03b9dba0 --- /dev/null +++ b/docs/sprout-pilot-validation-2026-10-25.md @@ -0,0 +1,121 @@ +# Sprout auth pilot: configuration validation + +Observed 25 October 2026. **Not activated; no Sprout sign-in or live auth email +has been tested.** This records read-only production checks and the remaining +activation steps, not a deployment or successful pilot. + +## Rootmail production + +- AWS account `130299713609`, region `us-east-1`; deployment identity + `claude-depoy`. SSM reports the consolidated ARM host online. +- API, worker and dashboard run `328f5ade6bcb9f916cba758360e07e9b7961420b`. + Admin runs `cd3d3ff90454d045167d0a750a70fbdc25942e6c`; marketing and developer + sites run `182928943604ddb3eb4ba79ec48fc0ae3a6fb7a1`. All six are healthy. + The older all-six-on-one-SHA checkpoint is historical. +- Host observation: 3,824 MiB RAM, 2,530 MiB available, no swap, 9.1 GiB free + disk (51% used). This is a point-in-time capacity check, not a load test. +- Auth environment flag, vault URL/key and auth SES set are unset. Production + has no `messages.auth_*` columns. PR #59 and migrations 0083/0084 are not deployed. +- SES sending enabled, enforcement HEALTHY, production access false. Quota: + 200/day and 1/second; zero sends in the preceding 24 hours at observation. +- `rootmail-platform` publishes SEND, DELIVERY, BOUNCE, COMPLAINT, DELIVERY_DELAY, + REJECT and RENDERING_FAILURE to the existing SNS topic. No OPEN/CLICK events. + Configuration-set archiving, tracking options and VDM overrides are absent; + account VDM settings are absent. Sending is enabled. +- The production worker's actual credentials initially received `AccessDeniedException` + from `GetConfigurationSetEventDestinations`. The deployment user's read succeeds. + Ordinary send permission does not establish permission for this auth safety check. +- Prepared [minimal runtime read policy](ops/auth-ses-read-policy.json): one + `ses:GetConfigurationSetEventDestinations` action on the exact + `rootmail-platform` ARN. After Robert's approval on 26 October, the console + confirmed `rootmail-auth-config-read` attached to `rootmail-app`. The same + runtime check then succeeded, returning only the expected non-engagement + events. The deployment identity still has no IAM administration permission. + +SSM evidence IDs (outputs contain no credentials or message content): +`02a2e6c2-b161-452a-89bd-100dbaf44aec` (host/images/config presence), +`9dad672e-de52-431f-b7ad-5ea5bb4b091c` (candidate workspaces/schema), +`9bc1944f-7af6-44c8-ae13-ceffb719a728` (runtime SES read denial). +The successful post-permission check is `a3f6fc13-eab2-40e1-a342-529de0e07c18`. + +## Sprout hosted settings + +The existing in-app GitHub sign-in refreshed successfully after Supabase reported +an expired session. No new login permission or project was created. + +- Project `sprout-dev-us`, reference `mhuvdulhieawrcdwughy`, US East (North + Virginia), Free plan, NANO, healthy. Latest migration shown: `personal_sync`. +- No Auth Hooks configured. Email provider and new signups enabled; email + confirmation enabled; anonymous sign-ins disabled. +- Eight-digit email OTP, 3,600-second expiry, secure email change enabled. +- Site URL remains `http://localhost:3000`; redirect allowlist empty. +- Custom SMTP is not configured; templates page says default templates apply. + No fallback delivery has been tested. +- Sign-in/signup and token-verification limits each show 30 requests/5 minutes + per IP. The displayed 60-second interval is labelled as applying to custom SMTP; + do not infer its hook behavior without testing. +- Native code inspection: the app requests `signInWithOTP`, then verifies an + entered code; the input accepts eight digits. No native session was observed. + +The local Sprout `supabase/README.md` still says no hosted project exists. +The newer Notion HT-005 record and this live inspection supersede that sentence. +Its working tree contains substantial unrelated native-app edits; this pass did +not rewrite or commit them. + +## Prepared pilot controls + +PR #59 now requires an explicit `AUTH_MESSAGE_SCOPES` JSON allowlist on both API +and worker, in addition to the disabled-by-default feature flag. Empty denies all; +workspace scope does not implicitly enable its clients. Removing a scope also +stops queued auth sends. A database-backed test verifies those boundaries. + +The adapter has an explicit `deliveryMode: "code_only"` for native OTP consumers. +It emits no verification URL, token hash or callback from the hook payload. +Default link mode keeps the exact HTTPS/native redirect allowlist. This avoids +sending Sprout users an unusable localhost link without weakening callback checks. + +The existing Sprout chat's Rootmail session selects **Robert Beta QA / Production**, +confirmed as `Rootmail Beta Acceptance 20261015`, live workspace +`ws_dgdl6gmn84qh5x8e93cqp5db`. Read-only database inspection confirmed no sending +suspension, normal reputation, the owner Gmail address as a verified recipient, +and its default verified beta sender. After explicit approval, temporary key +`key_6x28z8pmaf3ind40vsezeuq0` was created for at most eight owner-inbox checks. +It was encrypted for transfer to the host, decrypted only into a root-owned +0600 file on `/run`, and the local temporary copy removed. Revoke it after the +pilot and verify HTTP 401. It is a workspace key, not an endpoint-limited key. +No pilot email has yet been sent. + +The runnable isolated hook listener now passes two real HTTP fixture tests; +recipient admission brings the adapter suite to 40 passing tests. All seven +workspace test tasks pass (258 API tests), typecheck and all six builds pass, +and the design audit reports zero blockers. Production runtime-image CI must +still verify the newly added entrypoint/dependency packaging before release. + +## Remaining activation and acceptance + +1. Runtime SES read permission is applied and verified. Preserve its exact scope. +2. Finish the reviewed hook-host deployment and durable ledger configuration. + A dedicated HTTP listener and optional Compose overlay are now prepared; + no executable HTTPS hook is deployed yet. Use the existing host/database where + suitable; the observed capacity does not justify a new paid instance. +3. Release PR #59 through exact-main CI, images, reviewed migrations and the + guarded canary path. Provision a separate bounded non-persistent content vault; + verify no persistence/replicas/backups, no swap, protected networking and logs. +4. Use the exact QA scope, verified sender and confirmed owner inbox; keep its + server key out of the native app. Set the actual 3,600-second lifetime, + secure email-change mode and code-only delivery. Match API/worker allowlists. +5. Activate the Send Email Hook only after capability and non-delivery safety + checks pass. Keep the previous Supabase settings for rollback. Disabling the + hook returns to the untested default sender, not a verified SMTP fallback. +6. Request a controlled native code; separately record enqueue, provider ID, + delivery, inbox arrival and authenticated session with timestamps. Verify wrong + code, resend/expiry behavior and duplicate events without recording codes/links. + Revoke the temporary key and disable admission when the pilot ends. + +The root `pnpm test` command now pins mock mail/DNS and passes the selected fixture +environment to Turbo. A prior bare Turbo run filtered the fixture overrides; the +auth suite refused setup. The CI-equivalent run then passed, and the corrected +root test command passed as well. No test success here proves live delivery. + +References: [Supabase Send Email Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook), +[AWS SES IAM action/resource scope](https://docs.aws.amazon.com/service-authorization/latest/reference/list_sesv2.html). diff --git a/docs/supabase-auth-integration.md b/docs/supabase-auth-integration.md new file mode 100644 index 00000000..d242688a --- /dev/null +++ b/docs/supabase-auth-integration.md @@ -0,0 +1,281 @@ +# Supabase authentication email integration + +Implementation checkpoint: 24 October 2026, started from main `328f5ad` on +`codex/supabase-auth-integration`. This identifies the inspected source, not the +currently running production image. No live environment was modified or tested +in this increment. + +Task: [Rootmail / Sprout pilot](https://app.notion.com/p/4032db0e8691818496f5c235840c351c). + +Latest operational evidence: [25 October production and Sprout configuration +validation](sprout-pilot-validation-2026-10-25.md). The checks are read-only; +the integration is not deployed and the native pilot has not run. + +## First increment — signed planner + +`packages/supabase` contains a private, transport-free planner. It verifies raw +signed hooks, validates supported actions, maps recipients/codes/links, isolates +configuration and retry-key namespaces, and protects common accidental inspection +paths. See its README for the action matrix and test command. This is not a +published integration, a successful send, or an end-to-end Sprout acceptance. + +## Why live transport is gated + +The existing ordinary send path is designed for visible, auditable correspondence: + +- `apps/api/src/routes/messages.ts` renders and persists `renderedHtml`, + `renderedText`, variables and a content digest. Its Zod schema strips unknown + properties; a caller inventing an auth-policy field would not obtain privacy. +- `apps/worker/src/pipeline.ts` copies bodies to `openConversationForSend` before + sending. Suppressing a preview in the UI would leave these copies accessible. +- `apps/api/src/lib/serialize.ts` exposes thread bodies. A single send-response + redaction would not address conversation, export, support and proof surfaces. +- `packages/core/src/queue.ts` configures delayed/retried jobs without a per-message + credential expiry. A high-priority queue alone cannot prevent stale codes. +- `apps/api/src/routes/apikeys.ts` offers workspace/client isolation, but not + endpoint-level send-only permissions. Do not advertise that key as least privilege. + +These are requirements for the new auth path, not defects in retention of ordinary +customer correspondence. Keep existing transactional/marketing behavior unchanged. + +## Second increment — authentication content policy + +**Update, 25 October:** the server-side first version below is implemented on the +draft branch and tested with fake delivery. The numbered list remains the review +checklist for rollout; it is not a claim that production has this policy enabled. + +### Implemented server contract (disabled by default) + +`POST /v1/auth-messages` accepts only `policy: "authentication-v1"`, `to`, `from`, +optional `reply_to`, rendered `html`/`text`, absolute `expires_at` and a required +`idempotency_key`. Scope comes from the live API key and optional existing client +header. No templates, variables, metadata, tags, attachments or scheduling fields +are accepted. A generic subject is enforced; branding belongs in the protected +body. Conversation-capture reply addresses are refused. + +PostgreSQL records only the generic subject, sender/recipient, expiry, a private +keyed request commitment and normal delivery IDs/status/timings. A database CHECK +constraint prevents auth rows from retaining body, variables, metadata, body hash +or attachments. Existing retention/erasure can still replace the subject with +`[redacted]`. Public message/proof responses disclose the auth policy and do not +offer a content proof. No conversation is created. Request-body logging is redacted; +auth errors use fixed text, not parser/provider/Redis diagnostics. + +Bodies are AES-256-GCM encrypted with a dedicated key and scope/deadline binding +in a separate ephemeral Valkey instance. Writes use NX + absolute expiry; neither +replay nor retry renews it. Reads atomically GETDEL before the provider attempt. +Every write/read validates no RDB schedule, no AOF, no replicas, bounded memory and +noeviction; permission denial fails closed. This is deliberately unavailable on +the durable BullMQ cache. Configure no persistent volume, snapshots, host-memory +dumps, swap or APM payload capture. Runtime checks cannot prevent an administrator +manually snapshotting memory or changing configuration between checks. Encryption +does not promise forensic zeroing of process memory or the recipient's inbox. + +Admission preserves sender verification, recipient sandbox limits, tenant feature +gates, suppression, suspension and quota checks. Namespaced per-client keys and +private HMAC commitments reject conflicting replays. Only new records reserve +quota; a concurrent loser refunds it. Replays can recover a failed enqueue without +restoring content or changing the original deadline. Queue jobs contain only IDs, +use high priority and one attempt. Worker CAS + GETDEL prevents concurrent sends. +Manual resends of auth messages are refused: request a fresh credential upstream. + +The worker rechecks expiry before normal gates, before a throttle deferral, before +reading content and immediately before provider I/O. It refuses deadlines within +15 seconds of expiry or more than one hour away. **The caller must supply the +original credential deadline**; the server does not know Supabase issuance time. +The adapter still needs durable first-event expiry handling across re-signed hooks. +Rootmail cannot recall a message after SES accepts it or control SES's later SMTP +delivery attempts. A code may be invalidated upstream earlier by a resend. + +The initial real provider path is **platform SES only**, never BYO/dedicated-IP +fallback or mock simulation. Before sending, it reads the configured auth event +destinations and requires DELIVERY/BOUNCE/COMPLAINT with no enabled OPEN/CLICK. +Links additionally carry `ses:no-track`. The dedicated SDK path uses one attempt +and a five-second abort budget. Auth feedback ignores engagement URLs/user agents +and sanitizes bounce/complaint diagnostics. Ordinary mail tracking is unchanged. + +An ambiguous provider failure consumes the content and records an unknown outcome; +it does not automatically resend. A crash after claiming a send can leave a +`sending` record with unknown outcome. Do not turn it back into `queued`; a future +reconciliation sweep must classify stale claims without resending. This availability +tradeoff and provider latency still need pilot evaluation before activation. + +### Configuration and staged rollout gates + +- `AUTH_MESSAGES_ENABLED=false` by default on API and worker. No production flag + or infrastructure was changed in this implementation. +- `AUTH_CONTENT_REDIS_URL`: a separate bounded ephemeral store. A small container + on the existing host is a candidate, not a new managed AWS service commitment. + Validate isolation, authentication/TLS or loopback, persistence and memory budget + before enabling. The local test fixture uses 64 MB and no host data volume. +- `AUTH_CONTENT_KEY`: independent canonical base64-encoded 32-byte secret, shared + only between API/worker. Never copy the development fixture key. Disable + admission and drain/expire the vault before rotating; old queued ciphertext and + request commitments cannot be transparently recovered under a replacement key. +- `AUTH_SES_CONFIGURATION_SET`: an explicitly configured no-engagement set with + existing delivery/bounce/complaint feedback wired to Rootmail. Worker credentials + need read access to its event destinations. No AWS permission was added here. +- Apply journaled migration `0083_auth_content_policy` before starting the new + code. Deploy both API and worker with the flag **off**, verify tests/configuration, + then enable only for an approved pilot. Do not enable while an old worker can + consume auth jobs. Rollback: disable admission first; drain or expire pending + auth rows before reverting to a worker that does not understand the policy. +- Do not claim send-only credentials, high availability, exactly-once delivery, + full Supabase compatibility or a deployed integration. The private adapter is + still disconnected. Existing client-scoped keys retain broader capabilities. + +### Local security acceptance fixture + +Run PostgreSQL and queue Valkey on isolated test ports, then start a *separate* +auth vault (do not change persistence on an existing database/cache): + +```sh +docker run -d --name rootmail-auth-vault-local -p 127.0.0.1:6395:6379 \ + valkey/valkey:7.2-alpine valkey-server --save '' --appendonly no \ + --maxmemory 64mb --maxmemory-policy noeviction +``` + +Set `DATABASE_URL`/`REDIS_URL` to the test fixtures and +`AUTH_CONTENT_REDIS_URL=redis://127.0.0.1:6395`, `MAIL_PROVIDER=mock`, +`DNS_VERIFY_MODE=mock`. Run `pnpm db:migrate` against **that local database only**, +then the normal test command. CI creates its own ephemeral vault. The tests enable +the flag only inside their isolated process and inject fake provider responses. + +### Remaining policy review checklist + +Before connecting the planner to Rootmail's HTTPS API: + +1. Define a versioned, explicitly supported auth contract. Unsupported servers + must reject before accepting any secret. Reuse workspace/client authorization + and sender validation; never trust tenant/sender claims in hook metadata. +2. Keep codes, token-bearing URLs and rendered bodies out of normal message rows, + conversations, templates/variables, previews, searches, exports, support views, + proof artifacts, audit events and logs. Do not keep a bare body/code digest; + small OTP spaces can be brute-forced against it. Verify all read surfaces. +3. If asynchronous sending needs temporary content, use separately encrypted, + short-lived storage with an explicit key and deletion lifecycle. Queue only + IDs. Document persistence, backups, failed jobs, crash recovery and maximum + secret lifetime; a Redis TTL alone does not prove all copies are gone. +4. Preserve non-secret evidence: message/event IDs, workspace/client, recipient + where authorized, action, timings, provider ID, safe failure category. Avoid + provider exception text that may echo payloads. Enqueue, provider acceptance + and delivery must remain distinct observations. +5. Disable open/click rewriting and tracking for auth messages across supported + providers, including configuration-set behavior. Do not flip global defaults + or mislabel a pixel/scanner as verified human interaction. +6. Bind expiry to original credential issuance/first accepted event and configured + Supabase TTL with a conservative safety margin; retries must never extend it. + Check before admission, delayed/throttled retry and the final provider call. + Supabase may invalidate an older credential on resend; expiry alone does not + prove a token is usable. Do not claim the planner's signature time proves it. +7. Add durable recipient-level idempotency and payload-conflict handling scoped + to the authenticated project/workspace/client. Test concurrent retries and + two-recipient partial completion. Stable local keys alone are not a ledger. +8. Assess/enforce send-only API-key capabilities before calling the integration + least-privileged. Until then disclose the client's broader key authority. + +## Transport and pilot gates + +- Add the HTTPS transport only after the server policy exists. Bound total hook + work, not each recipient independently: Supabase documents a five-second HTTP + hook budget. Account for verification, cold starts and both recipients; avoid + stacking an adapter retry loop on upstream retries. +- Test revoked keys, suppression, quota/rate rejection, provider failure, slow + response, ambiguous timeout, sandbox simulation and partial recipient success. + Do not silently fall back to ordinary sending, a different tenant or provider. +- Explicitly distinguish accepted queue work from a real provider send. Never + acknowledge sandbox simulation as a production auth email. +- Complete an independently usable second-customer guide and test it with mock + credentials first. Keep package private until the actual delivery path works. +- Separately authorize a confirmed-inbox Sprout pilot: request code, observe + queue/provider/delivery timestamps, enter code, verify native Supabase session. + Include wrong/expired codes and resend limits. Record IDs/times, never codes. +- Verify current provider access/identity/quota and production version before + enabling wider signups. No SES appeal/provider strategy change is authorized + by implementation of this task. Measure latency before promising an SLO. + +## Verification scope + +First-increment checks completed on 25 October 2026 (WAT): 27/27 new unit tests, +workspace typecheck (13 packages; 14 successful Turbo tasks including the SDK +build dependency), design/truth audit (zero blocking violations), frozen offline +dependency installation and `git diff --check`. These are local results, not a +production test or CI release result. The full database-backed suite and app build +were not run locally for this isolated, runtime-disconnected package. + +The planner's unit suite checks signature rejection, time window, rotation, +action matrix, secure/single email changes, malformed payloads, retry-key scope, +redirect allowlisting, HTML escaping and redacted default inspection. Tests use +synthetic fixtures only. They do not exercise a real Rootmail tenant key, a provider, +a native app or persistence; those first-increment checks alone do not establish acceptance. + +Second-increment checks on 25 October (WAT): 22 new security tests (6 core vault, +4 SES transport, 12 database-backed API/worker tests), alongside the 27 planner +tests. These exercise encrypted storage/scope/expiry, unsafe vault rejection, +strict input handling, database privacy constraints, client isolation, conflicting +replays, concurrent admission/worker claims, stale-worker failures, deferred queue +settings, revoked keys, suppression, kill switches, mock refusal and sanitized +provider feedback. All delivery is injected or mocked; no live mail is sent. + +The workspace test, typecheck and build gates passed locally (25 Turbo tasks, +including six builds). The design/truth audit and whitespace check passed. +Parallel API-file runs exposed an existing shared rootmail-hq daily-counter race +in the system-mail-preview tests. API files now run sequentially; explicit +concurrent-admission/send checks still run concurrently inside their tests. The +full run passed after this test-harness change. This does not alter production +limits or sending behavior. Run build and typecheck in sequence: running both at +once raced Next.js generated types locally (TS6053); that is not an application +type error. Remote CI must be checked against the pushed head; +an earlier planner-only CI result is not evidence for this increment. + +## Third increment — bounded hook transport and recovery + +Implemented on 25 October, still private/unreleased. `createSupabaseEmailHook` +mounts a Web Request handler using the existing signed planner. It preflights +policy, provider and authenticated workspace/client before posting secrets; +unsupported or mock servers fail closed. A shared maximum 4.5-second deadline +includes bounded upload/response streaming, ledger work, both recipients and +backed-off status polling. No POST retry loop or ordinary-send fallback exists. +Success requires observed SES acceptance for every recipient, not queueing. + +`createPostgresHookLedger` uses the customer's existing server-only PostgreSQL +pool. Its atomic insert-or-read freezes a scoped event's keyed commitment and +first expiry across concurrent requests, re-signing and handler restarts. No +recipient, code, body or unkeyed content hash enters this table. Conflicts fail +closed; expired records remain tombstones. Partial recipient completion and +ambiguous HTTP responses retry against Rootmail's existing recipient keys. +Automatic ledger pruning and transparent HMAC-key rotation are not implemented. + +The initial deadline reserves five minutes from the configured 10–60 minute +lifetime. Signature time is not proven credential issuance: delayed first +observations and Supabase resend invalidation still require live validation. +This is not a guarantee that every queued credential remains usable upstream. + +The existing sequence tick now runs a bounded, feature-gated auth reconciliation +sweep. After expiry plus one minute it classifies expired queued work or unknown +claimed-send outcomes, with an atomic audit entry and no resend. Migration 0084 +adds a partial pending-auth expiry index. Ordinary messages are excluded. A sweep +failure is isolated from sequence processing. Completed queue logs now say +"processed send job", not "sent", since a job may have been refused or deferred. + +The earlier sections describe the first two increments: the previously listed +missing transport/ledger/reconciliation now exist on this branch. Remaining +release gates are operational configuration/privacy checks, pilot latency and +actual expiry/resend behavior, a real Supabase/native session, and an independent +customer walkthrough. No provider or production change is authorized by these +implementation checks. See [setup reference](supabase-hook-setup.md). + +Third-increment focused checks: 11 new hook tests (38 package tests total), plus +three new API integration tests (15 auth-path tests total). The real local +PostgreSQL/queue/API/worker test observed two synthetic provider acceptances and +no additional send on a re-signed retry. Concurrent ledger claims retained one +deadline; a new ledger instance read the same record. Stale-claim reconciliation +kept sent evidence intact and never resent. No network mail was sent. Full +workspace/CI results are recorded in the PR and Notion against the final commit. + +Configs touched: private package configuration/lockfile (first increment), disabled +auth environment settings/template, migration schema/journal, API test scheduling +and CI's isolated test vault; migration 0084 and reference customer-ledger SQL/setup +in the third increment. Prod behaviour preserved: ordinary sends keep their behavior; auth is off by +default. No production migration, environment change, deployment, live email, +production key creation or Supabase configuration update was performed. diff --git a/docs/supabase-hook-setup.md b/docs/supabase-hook-setup.md new file mode 100644 index 00000000..5ff51802 --- /dev/null +++ b/docs/supabase-hook-setup.md @@ -0,0 +1,189 @@ +# Supabase → Rootmail hook setup reference + +Unreleased reference, 25 October 2026. This has passed synthetic/local tests, +not an independently followed customer installation or live Sprout sign-in. +Keep the Supabase hook disabled until the production release/pilot gates below +are explicitly cleared. No SMTP fallback is invoked by this adapter. + +## Prerequisites and cost + +- Rootmail API/worker with migrations 0083 and 0084, auth feature enabled only + after vault isolation and SES no-engagement feedback are verified. The default + remains off. Capability preflight rejects older servers, wrong scope and mock + delivery before submitting a code. Provider rejection can still occur later. + Both API and worker also require `AUTH_MESSAGE_SCOPES` to contain the exact + pilot pair, for example `[{"workspaceId":"ws_pilot","subTenantId":null}]`. + The empty default denies everyone; a workspace entry never admits its clients + implicitly. Removing an entry blocks queued auth mail at the worker too. +- A verified sender, confirmed test inboxes under sandbox restrictions, and a + live key in the intended workspace/client. These keys currently have broader + authority than sending alone. Never expose them to a browser or native app. +- A Node.js server route with HTTPS and a nearby existing PostgreSQL database + for replay metadata. This does not require another managed database or queue. + The Rootmail encrypted-content vault is separate from this customer ledger. +- Supabase Email Provider enabled; project URL, Send Email Hook secret, secure + email-change setting, exact callback allowlist and OTP lifetime known. This + reference supports configured lifetimes from 600 to 3600 seconds. Do not + silently assume a default. Supabase still issues and verifies every code. + +## Install and configure + +1. Pin the reviewed PR #59 source version. `@rootmail/supabase` is private and + unpublished; do not install an unrelated public package with that name. + Vendor `packages/supabase` as a workspace dependency and install its locked + dependencies. With Next.js, include `@rootmail/supabase` in `transpilePackages` + because this reference exports TypeScript source. Use the Node runtime. +2. Apply `packages/supabase/examples/ledger.sql` to the hook host's existing + database using an admin/migration role. Grant a dedicated server-only runtime + role USAGE on `rootmail_auth_hook` and SELECT, INSERT, UPDATE on its `events` + table. No browser-role grants and no public Data API exposure. Do not run this + SQL automatically on an incoming request. Use TLS for remote connections. +3. Store the following only in the server secret manager. Generate an independent + 32-byte base64 ledger HMAC key; do not reuse the API key or webhook secret. + Record configuration separately from secrets so rotation/rollback is possible. + +Example Next.js App Router server route (install `postgres` in the host project): + +```ts +import postgres from "postgres"; +import { createPostgresHookLedger, createSupabaseEmailHook } from "@rootmail/supabase"; + +export const runtime = "nodejs"; +const required = (name: string) => { + const value = process.env[name]; + if (!value) throw new Error("Missing hook configuration"); + return value; +}; +const database = postgres(required("HOOK_DATABASE_URL"), { + max: 2, connect_timeout: 1, + connection: { statement_timeout: 1000 }, +}); +const ledger = createPostgresHookLedger((query, values) => + database.unsafe(query, [...values])); + +export const POST = createSupabaseEmailHook({ + hookSecrets: [required("SUPABASE_EMAIL_HOOK_SECRET")], + supabaseUrl: required("SUPABASE_PROJECT_URL"), + workspaceId: required("ROOTMAIL_WORKSPACE_ID"), + clientId: process.env.ROOTMAIL_CLIENT_ID || null, + rootmailUrl: required("ROOTMAIL_API_ORIGIN"), // HTTPS origin only; no /v1 suffix + rootmailApiKey: required("ROOTMAIL_LIVE_KEY"), + from: required("AUTH_FROM_EMAIL"), + replyTo: required("AUTH_REPLY_EMAIL"), // real support inbox, not reply+ capture + brand: required("AUTH_BRAND"), + emailChangeMode: "secure", // must match the actual Supabase setting + allowedRedirects: JSON.parse(required("AUTH_ALLOWED_REDIRECTS_JSON")), + defaultRedirect: required("AUTH_DEFAULT_REDIRECT"), + credentialLifetimeSeconds: Number(required("SUPABASE_OTP_TTL_SECONDS")), + ledgerKey: required("AUTH_HOOK_LEDGER_KEY"), + ledger, +}); +``` + +The framework must pass the original Request body, not parse/re-serialize JSON. +For a native app that verifies codes directly, set `deliveryMode: "code_only"` +and omit `allowedRedirects` and `defaultRedirect`. This mode includes the issued +code but no verification link, token hash, or payload redirect. It does not +change Supabase's token verification or expiry. The default `code_and_link` +mode still requires exact permitted HTTPS/native callbacks and rejects localhost. +Exclude only this hook route from application-session middleware; authenticity is +enforced by Standard Webhooks signatures. Keep the rest of the app authenticated. +Disable body/query capture in reverse-proxy logs, APM, error tracking and function +recordings. The handler does not log payloads; it cannot configure hosting tools. +Place the route near Rootmail and the ledger database; cold starts and network +transit consume Supabase's five-second budget before/after the handler's 4.5s cap. + +## Retry and expiry semantics + +### Controlled Sprout host + +The optional `apps/api/src/cli/supabase-hook-server.ts` entrypoint runs from the +same immutable API image, with its own environment and port 4011. It serves only +the exact POST `/v1/hooks/supabase/sprout` and GET `/health`. It preserves signed +bytes, rejects duplicate signature headers, caps connections/timeouts and emits +fixed errors without access/body logs. It requires `deliveryMode: "code_only"` +and an explicit `allowedRecipients` list in `SUPABASE_HOOK_CONFIG_JSON`; all other +configuration fields above belong in that JSON. Keep `HOOK_DATABASE_URL` limited +to the ledger role. TLS certificate verification is required; optionally mount +the database CA at `HOOK_DATABASE_CA_FILE`. + +`docs/ops/sprout-pilot.compose.yml` is an opt-in deployment overlay, not enabled +by the regular release. It bounds the hook to 192 MiB and the separate Valkey +vault to 96 MiB, with no swap, core dumps, persistence or published vault port. +The host must also have no swap/hibernation or memory backups. The vault uses +an internal network and a private ACL file; disable the default user and permit +only the auth user's `rootmail-auth:*` keys and required connection commands, +`CONFIG GET`, `ROLE`, `SET`, `GETDEL`, `DEL`. Never grant `CONFIG SET`, replication, +`MONITOR` or persistence commands. Verify the production ACL with the actual +API/worker client before admission. Preserve this network in the host overlay +used by later deploys; a standalone overlay does not update deploy-host.sh. + +Route only the exact HTTPS hook path from Caddy to `sprout-hook:4011` on the +private application network. Disable access logging for that path, preserve the +body and signature headers, and do not introduce a proxy retry. Activate the +Supabase hook only after signature rejection, capability preflight and ledger +permissions pass. Stop the hook, clear the admitted scope and revoke the pilot +key after acceptance; keep replay tombstones. This is a temporary owner-inbox +pilot, not public Sprout email availability. + +- A hook 200 means every recipient has an observed SES provider ID and `sent` or + `delivered` status. It does not mean inbox placement, human access, or a valid + Supabase session. Merely queued work is polled, never acknowledged as sent. +- One shared deadline bounds body streaming, ledger work, both recipient POSTs + and status polling. There is no nested POST retry loop. Errors have fixed text; + raw upstream bodies are discarded. Redirects are refused so keys cannot follow + a redirect to another host. Do not add a hosting-layer retry that changes IDs. +- The ledger stores only a scoped event digest, a keyed commitment and fixed + expiry. A re-signed/restarted retry reads the same expiry; changed content under + the same event is refused. Rootmail deduplicates each recipient independently. + A partial failure can be retried under the same event without resending the + recipient already accepted. An ambiguous network response is not proof of no send. +- First observation uses the earlier of server time/signature time plus the + configured lifetime, minus a five-minute reserve. This is conservative, **not + proof of original token issuance**. A delayed first hook or Supabase resend can + invalidate a token earlier. Verify actual expiry/resend behavior during pilot. +- Keep expired ledger rows as replay tombstones. Do not delete/reset the ledger + on redeploy, auto-prune it, rotate its key transparently, or give each replica an + in-memory store. Erasing these rows loses replay history. Review a bounded + retention policy before high-volume release; a production retention automation + has not been implemented. +- A Rootmail send interrupted after its claim is never automatically retried. + After expiry plus one minute, a bounded sweep records an unknown send outcome + (or expired queued work), without replaying content. This is an availability + tradeoff, not a promise of exactly-once delivery. Disabling the auth flag also + disables this sweep; pending ciphertext still has its absolute expiry. + +## Troubleshooting, rotation and rollback + +- 401/403: verify hook secret, server key, client/workspace scope and revocation; + never route around an authorization failure. +- 402/429: quota/rate limit. Do not switch provider or lift beta limits automatically. +- 409/422: conflicting replay, suppressed/failed send, unsupported action or expiry; + inspect only non-secret message IDs/status. A new sign-in request must originate + with Supabase; never reuse an expired code or manually retry an auth message. +- 503: unsupported/disabled server, unsafe provider setup, database/network + failure or deadline reached. Check Rootmail's message evidence before assuming + nothing was sent. A late provider acceptance may follow a hook timeout. +- Rotate hook secrets with two-key overlap, validate both, then remove the old + secret. API-key rotation must keep the same scope. Ledger-key rotation requires + draining/expiring in-flight events and retaining tombstones; old-event retries + will fail the commitment check. Do not change keys to make a replay pass. +- Before activation, record the previous Supabase email settings and verify the + fallback sender separately. Roll back by disabling the Send Email Hook and + restoring that tested configuration. A disabled hook does not prove SMTP works. + Roll Rootmail back only after disabling admission and draining/expiring auth jobs. + +## Pilot gates (not yet observed) + +Confirm the deployed version/configuration and exact sender/inboxes; authorize +one live Sprout test; request the code, observe provider acceptance and delivery +separately, enter the code and verify the native Supabase session. Test wrong and +expired codes, resend invalidation and two-recipient change. Record IDs/timestamps +and latency, never codes or links. Then have a second customer follow this guide. + +Sources: [Supabase email hook and recipient mapping](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook), +[hook errors and five-second budget](https://supabase.com/docs/guides/auth/auth-hooks). + +Configs touched: reference hook-host environment, server-only PostgreSQL schema +and pool settings; no settings were applied to a customer or production host. +Prod behaviour preserved: auth remains disabled and no live emails were sent. diff --git a/package.json b/package.json index 830650de..c0ea0955 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "marketing": "pnpm --filter @rootmail/marketing dev", "dashboard": "pnpm --filter @rootmail/dashboard dev", "admin": "pnpm --filter @rootmail/admin dev", - "test": "turbo run test" + "test": "MAIL_PROVIDER=mock DNS_VERIFY_MODE=mock turbo run test --env-mode=loose" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/core/src/auth-content.test.ts b/packages/core/src/auth-content.test.ts new file mode 100644 index 00000000..59d1ddd2 --- /dev/null +++ b/packages/core/src/auth-content.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Redis } from "ioredis"; +import { AuthContentError, AuthContentVault, assertAuthLifetime, authRequestFingerprint, openAuthContent, sealAuthContent } from "./auth-content"; + +const key = Buffer.alloc(32, 21).toString("base64"); +const content = { html: "

fixture-secret-123456

", text: "fixture-secret-123456" }; +const scope = () => ({ id: "msg_fixture", workspaceId: "ws_fixture", subTenantId: "st_fixture", authExpiresAt: new Date(Date.now() + 60_000) }); +const safeError = (code: string) => (e: unknown) => e instanceof AuthContentError && e.message === code && e.cause === undefined; + +test("auth content is randomized authenticated ciphertext, never a plaintext or bare-code digest", () => { + const s = scope(); + const first = sealAuthContent(content, s, key); + assert.notEqual(first, sealAuthContent(content, s, key)); + assert.ok(!first.includes(content.text)); + assert.deepEqual(openAuthContent(first, s, key), content); + assert.notEqual(authRequestFingerprint(content, key), authRequestFingerprint(content, Buffer.alloc(32, 22).toString("base64"))); +}); + +test("ciphertext cannot move to a different message, workspace, client or deadline", () => { + const s = scope(); + const sealed = sealAuthContent(content, s, key); + for (const patch of [{ id: "msg_other" }, { workspaceId: "ws_other" }, { subTenantId: null }, { authExpiresAt: new Date(s.authExpiresAt.getTime() + 1) }]) { + assert.throws(() => openAuthContent(sealed, { ...s, ...patch }, key), safeError("auth_content_unavailable")); + } + assert.throws(() => openAuthContent(sealed, s, Buffer.alloc(32, 3).toString("base64")), safeError("auth_content_unavailable")); + assert.throws(() => openAuthContent("secret-123456", s, key), safeError("auth_content_unavailable")); +}); + +test("expiry checks reject stale, near-expiry, invalid and overlong deadlines", () => { + const now = Date.now(); + for (const value of [now - 1, now + 15_000, now + 3_600_001, NaN]) { + assert.throws(() => assertAuthLifetime(new Date(value), now), safeError("auth_expired")); + } + assertAuthLifetime(new Date(now + 15_001), now); +}); + +test("vault rejects persistent, replicated, evicting or uninspectable stores", async () => { + for (const variant of ["save", "appendonly", "maxmemory-policy", "unbounded", "replica", "denied"]) { + const redis = { + config: async () => { + if (variant === "denied") throw new Error(content.text); + return ["save", variant === "save" ? "3600 1" : "", "appendonly", variant === "appendonly" ? "yes" : "no", + "maxmemory-policy", variant === "maxmemory-policy" ? "allkeys-lru" : "noeviction", "maxmemory", variant === "unbounded" ? "0" : "67108864"]; + }, + role: async () => ["master", 0, variant === "replica" ? ["replica"] : []], + } as unknown as Redis; + await assert.rejects(new AuthContentVault(redis, key).assertEphemeral(), safeError("auth_unavailable")); + } +}); + +test("vault stores only ciphertext with absolute NX expiry and consumes exactly once", async () => { + let stored: string | null = null; + let args: unknown[] = []; + const redis = { + config: async () => ["save", "", "appendonly", "no", "maxmemory-policy", "noeviction", "maxmemory", "67108864"], + role: async () => ["master", 0, []], + set: async (...values: unknown[]) => { args = values; if (stored) return null; stored = values[1] as string; return "OK"; }, + getdel: async () => { const value = stored; stored = null; return value; }, + del: async () => { stored = null; return 1; }, + } as unknown as Redis; + const vault = new AuthContentVault(redis, key); + const s = scope(); + await vault.put(s, content); + assert.deepEqual(args.slice(2), ["PXAT", s.authExpiresAt.getTime(), "NX"]); + assert.ok(!JSON.stringify(args).includes(content.text)); + await assert.rejects(vault.put(s, content), safeError("auth_content_unavailable")); + assert.deepEqual(await vault.take(s), content); + await assert.rejects(vault.take(s), safeError("auth_content_unavailable")); +}); + +test("bad content and weak/malformed encryption keys never leak in errors", () => { + assert.throws(() => sealAuthContent({ html: "", text: content.text }, scope(), key), safeError("auth_content_invalid")); + assert.throws(() => sealAuthContent(content, scope(), "fixture-secret-123456"), safeError("auth_unavailable")); +}); diff --git a/packages/core/src/auth-content.ts b/packages/core/src/auth-content.ts new file mode 100644 index 00000000..5ee29b34 --- /dev/null +++ b/packages/core/src/auth-content.ts @@ -0,0 +1,144 @@ +import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto"; +import { Redis } from "ioredis"; +import { z } from "zod"; +import { env } from "./env"; + +export const AUTH_CONTENT_POLICY = "authentication-v1"; +export const AUTH_SUBJECT = "Authentication email"; +export const AUTH_MAX_LIFETIME_MS = 60 * 60 * 1000; +export const AUTH_SEND_MARGIN_MS = 15_000; + +/** Both admission and worker recheck this so removing a pilot stops queued work. */ +export function authScopeEnabled(workspaceId: string, subTenantId: string | null): boolean { + return env.AUTH_MESSAGES_ENABLED && env.AUTH_MESSAGE_SCOPES.some(scope => + scope.workspaceId === workspaceId && scope.subTenantId === subTenantId); +} +const contentSchema = z.object({ html: z.string().min(1).max(64_000), text: z.string().max(32_000) }).strict(); +export type AuthContent = z.infer; +export interface AuthContentScope { + id: string; + workspaceId: string; + subTenantId: string | null; + authExpiresAt: Date; +} + +export class AuthContentError extends Error { + constructor(readonly code: "auth_unavailable" | "auth_expired" | "auth_content_unavailable" | "auth_content_invalid") { + super(code); + this.name = "AuthContentError"; + } +} + +export function assertAuthLifetime(expiresAt: Date, now = Date.now()): void { + const remaining = expiresAt.getTime() - now; + if (!Number.isFinite(remaining) || remaining <= AUTH_SEND_MARGIN_MS || remaining > AUTH_MAX_LIFETIME_MS) { + throw new AuthContentError("auth_expired"); + } +} + +function keyBytes(secret: string): Buffer { + const key = Buffer.from(secret, "base64"); + if (key.length !== 32 || key.toString("base64") !== secret) throw new AuthContentError("auth_unavailable"); + return key; +} +const binding = (scope: AuthContentScope) => JSON.stringify([ + AUTH_CONTENT_POLICY, scope.id, scope.workspaceId, scope.subTenantId, scope.authExpiresAt.getTime(), +]); + +/** No bare OTP/body hashes: a keyed, domain-separated commitment is private to the server. */ +export function authRequestFingerprint(value: unknown, secret = env.AUTH_CONTENT_KEY ?? ""): string { + return createHmac("sha256", keyBytes(secret)).update("rootmail-auth-request-v1\0").update(JSON.stringify(value)).digest("hex"); +} + +export function sealAuthContent(content: AuthContent, scope: AuthContentScope, secret: string): string { + assertAuthLifetime(scope.authExpiresAt); + const parsed = contentSchema.safeParse(content); + if (!parsed.success) throw new AuthContentError("auth_content_invalid"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", keyBytes(secret), iv); + cipher.setAAD(Buffer.from(binding(scope))); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(parsed.data), "utf8"), cipher.final()]); + return ["v1", iv.toString("base64"), cipher.getAuthTag().toString("base64"), ciphertext.toString("base64")].join("."); +} + +export function openAuthContent(sealed: string, scope: AuthContentScope, secret: string): AuthContent { + assertAuthLifetime(scope.authExpiresAt); + try { + const [version, iv, tag, ciphertext, extra] = sealed.split("."); + if (version !== "v1" || extra !== undefined || !ciphertext) throw new Error(); + const decipher = createDecipheriv("aes-256-gcm", keyBytes(secret), Buffer.from(iv, "base64")); + decipher.setAAD(Buffer.from(binding(scope))); + decipher.setAuthTag(Buffer.from(tag, "base64")); + const raw = Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]); + return contentSchema.parse(JSON.parse(raw.toString("utf8"))); + } catch { throw new AuthContentError("auth_content_unavailable"); } +} + +export class AuthContentVault { + constructor(private readonly redis: Redis, private readonly secret: string) { keyBytes(secret); } + + /** Checked on every put/take; any missing permission or unsafe setting fails closed. */ + async assertEphemeral(): Promise { + try { + const settings = await this.redis.config("GET", "save", "appendonly", "maxmemory-policy", "maxmemory") as string[]; + const values = Object.fromEntries(Array.from({ length: settings.length / 2 }, (_, i) => [settings[i * 2], settings[i * 2 + 1]])); + const role = await this.redis.role() as [string, number, unknown[]]; + if (values.save !== "" || values.appendonly !== "no" || values["maxmemory-policy"] !== "noeviction" || !(Number(values.maxmemory) > 0) || + role[0] !== "master" || !Array.isArray(role[2]) || role[2].length) throw new Error(); + } catch { throw new AuthContentError("auth_unavailable"); } + } + + async put(scope: AuthContentScope, content: AuthContent): Promise { + await this.assertEphemeral(); + const sealed = sealAuthContent(content, scope, this.secret); + try { + const result = await this.redis.set(this.key(scope), sealed, "PXAT", scope.authExpiresAt.getTime(), "NX"); + if (result !== "OK") throw new Error(); // never overwrite or extend a lifetime + } catch { throw new AuthContentError("auth_content_unavailable"); } + } + + async take(scope: AuthContentScope): Promise { + await this.assertEphemeral(); + assertAuthLifetime(scope.authExpiresAt); + let sealed: string | null; + try { sealed = await this.redis.getdel(this.key(scope)); } + catch { throw new AuthContentError("auth_content_unavailable"); } + if (!sealed) throw new AuthContentError("auth_content_unavailable"); + return openAuthContent(sealed, scope, this.secret); + } + + async discard(scope: AuthContentScope): Promise { + try { await this.redis.del(this.key(scope)); } + catch { /* The absolute TTL remains; never print a Redis error with command arguments. */ } + } + + private key(scope: AuthContentScope): string { + // Scope is authenticated again by GCM, including the deadline. + return `rootmail-auth:${scope.workspaceId}:${scope.id}`; + } +} + +let connection: Redis | undefined; +let vault: AuthContentVault | undefined; +export function getAuthContentVault(): AuthContentVault { + if (!env.AUTH_MESSAGES_ENABLED || !env.AUTH_CONTENT_REDIS_URL || !env.AUTH_CONTENT_KEY) throw new AuthContentError("auth_unavailable"); + if (vault) return vault; + try { + keyBytes(env.AUTH_CONTENT_KEY); + const url = new URL(env.AUTH_CONTENT_REDIS_URL); + const queue = new URL(env.REDIS_URL); + if (!["redis:", "rediss:"].includes(url.protocol) || + (url.hostname === queue.hostname && (url.port || "6379") === (queue.port || "6379"))) throw new Error(); + connection = new Redis(env.AUTH_CONTENT_REDIS_URL, { maxRetriesPerRequest: 1, connectTimeout: 1000, + commandTimeout: 1500, retryStrategy: () => null }); + connection.on("error", () => { /* Fixed safe errors are returned by vault operations. */ }); + vault = new AuthContentVault(connection, env.AUTH_CONTENT_KEY); + return vault; + } catch { throw new AuthContentError("auth_unavailable"); } +} +export async function closeAuthContentVault(): Promise { + const open = connection; + connection = undefined; + vault = undefined; + if (open) await open.quit().catch(() => open.disconnect()); +} diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index ce0d91e9..01da9269 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -30,6 +30,23 @@ const EnvSchema = z.object({ DATABASE_URL: z.string().min(1).default("postgres://rootmail:rootmail@localhost:5432/rootmail"), REDIS_URL: z.string().min(1).default("redis://localhost:6379"), + // Opt-in auth-content path. Never reuse the durable BullMQ cache as its vault. + AUTH_MESSAGES_ENABLED: z.enum(["true", "false"]).default("false").transform(v => v === "true"), + // Exact pilot scopes, including null for workspace-level mail. No wildcard or + // implicit inclusion of the workspace's clients; empty means nobody admitted. + AUTH_MESSAGE_SCOPES: z.string().default("[]").transform((value, ctx) => { + try { + return z.array(z.object({ workspaceId: z.string().regex(/^ws_[A-Za-z0-9_-]+$/), + subTenantId: z.string().regex(/^tnt_[A-Za-z0-9_-]+$/).nullable() }).strict()).max(20).parse(JSON.parse(value)); + } catch { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid authentication scope allowlist" }); + return z.NEVER; + } + }), + AUTH_CONTENT_REDIS_URL: z.string().optional(), + AUTH_CONTENT_KEY: z.string().optional(), // independent 32-byte base64 AES/HMAC key + AUTH_SES_CONFIGURATION_SET: z.string().optional(), // must exclude OPEN/CLICK events + API_PORT: z.coerce.number().int().positive().default(4000), API_HOST: z.string().default("0.0.0.0"), // How many reverse-proxy hops to trust when deriving the client IP (used for diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4b404be4..6bf74356 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,3 +31,4 @@ export * from "./lifecycle-email"; export * from "./envelope"; export * from "./byo"; export * from "./smtp-guard"; +export * from "./auth-content"; diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts index 94c45013..c67707aa 100644 --- a/packages/core/src/queue.ts +++ b/packages/core/src/queue.ts @@ -45,6 +45,8 @@ export function getSendQueue(): Queue { } export interface EnqueueOptions { + /** Authentication content is consumed once; never automatically retry an ambiguous send. */ + singleAttempt?: boolean; priority?: Priority; /** Delay before the job becomes available (used for scheduled `send_at`). */ delayMs?: number; @@ -60,7 +62,7 @@ export async function enqueueSend(data: SendJobData, opts: EnqueueOptions = {}) return queue.add("send", data, { priority: QUEUE_PRIORITY[opts.priority ?? "normal"], delay: opts.delayMs && opts.delayMs > 0 ? opts.delayMs : undefined, - attempts: 4, + attempts: opts.singleAttempt ? 1 : 4, backoff: { type: "exponential", delay: 5_000 }, removeOnComplete: { age: 86_400, count: 5_000 }, removeOnFail: { age: 7 * 86_400 }, @@ -86,10 +88,11 @@ export async function enqueueSend(data: SendJobData, opts: EnqueueOptions = {}) * regardless by `processSend`, which returns unless the message row is still * queued/sending: the row is the real lock, the job id is only a shortcut. */ -export async function enqueueSendDeferred(data: SendJobData, delayMs: number): Promise { +export async function enqueueSendDeferred(data: SendJobData, delayMs: number, opts: Pick = {}): Promise { await getSendQueue().add("send", data, { delay: Math.max(0, delayMs), - attempts: 4, + ...(opts.priority ? { priority: QUEUE_PRIORITY[opts.priority] } : {}), + attempts: opts.singleAttempt ? 1 : 4, backoff: { type: "exponential", delay: 5_000 }, removeOnComplete: { age: 86_400, count: 5_000 }, removeOnFail: { age: 7 * 86_400 }, diff --git a/packages/db/migrations/0083_auth_content_policy.sql b/packages/db/migrations/0083_auth_content_policy.sql new file mode 100644 index 00000000..0ea4d67f --- /dev/null +++ b/packages/db/migrations/0083_auth_content_policy.sql @@ -0,0 +1,13 @@ +ALTER TABLE "messages" ADD COLUMN "auth_expires_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "messages" ADD COLUMN "auth_request_fingerprint" text; +--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_auth_content_private" CHECK ( + "auth_expires_at" IS NULL OR ( + "rendered_html" IS NULL AND "rendered_text" IS NULL AND "content_hash" IS NULL + AND "subject" IN ('Authentication email', '[redacted]') AND "variables" = '{}'::jsonb + AND "metadata" = '{}'::jsonb AND "tags" = '[]'::jsonb AND "attachments" = '[]'::jsonb + AND "template_id" IS NULL AND "campaign_id" IS NULL AND "sequence_id" IS NULL + AND "type" = 'transactional' AND NOT "sandbox" + ) +); diff --git a/packages/db/migrations/0084_auth_reconcile_index.sql b/packages/db/migrations/0084_auth_reconcile_index.sql new file mode 100644 index 00000000..a323a149 --- /dev/null +++ b/packages/db/migrations/0084_auth_reconcile_index.sql @@ -0,0 +1,3 @@ +-- Only pending auth rows participate; ordinary mail is excluded. +CREATE INDEX "messages_auth_pending_expiry_idx" ON "messages" ("auth_expires_at") +WHERE "auth_expires_at" IS NOT NULL AND "status" IN ('queued', 'sending'); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 2be05b00..fc3fe5ed 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -582,6 +582,20 @@ "when": 1787758180829, "tag": "0082_byo_provider", "breakpoints": true + }, + { + "idx": 83, + "version": "7", + "when": 1792886400000, + "tag": "0083_auth_content_policy", + "breakpoints": true + }, + { + "idx": 84, + "version": "7", + "when": 1792886400001, + "tag": "0084_auth_reconcile_index", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0b357b60..77f163b5 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1285,6 +1285,10 @@ export const messages = pgTable( renderedText: text("rendered_text"), // sha256 of the rendered HTML — proves *what* was sent (Layer 3 / proof bundles). contentHash: text("content_hash"), + // Auth mail stores no content in this row. Ciphertext lives only in the ephemeral vault. + // Migration 0083's messages_auth_content_private CHECK enforces this across writers. + authExpiresAt: timestamp("auth_expires_at", { withTimezone: true }), + authRequestFingerprint: text("auth_request_fingerprint"), // keyed, private; never a proof digest sendAt: timestamp("send_at", { withTimezone: true }), priority: priorityEnum("priority").notNull().default("normal"), tags: jsonb("tags").$type().notNull().default([]), diff --git a/packages/supabase/README.md b/packages/supabase/README.md new file mode 100644 index 00000000..dac2bf42 --- /dev/null +++ b/packages/supabase/README.md @@ -0,0 +1,130 @@ +# Supabase email adapter — unreleased reference + +**Unreleased and private. Do not enable this as a production Supabase hook yet.** +The package now includes both a transport-free planner and an opt-in Web Request +handler that uses the auth-only Rootmail HTTPS endpoint. No running application +installs it automatically. Local transport tests use fake delivery; no real +Supabase session has been completed. Never use ordinary `/v1/messages` for codes. + +Update: the draft branch now includes a separate, disabled-by-default +`POST /v1/auth-messages` server contract. It has encrypted ephemeral content, +expiry enforcement and a restricted SES path with tracking checks. This planner +now has an explicit HTTP wrapper, capability preflight and PostgreSQL replay +ledger. See the [customer setup reference](../../docs/supabase-hook-setup.md) and +integration notes for rollout gates and remaining limitations. + +Tracking task: [Supabase authentication emails — Sprout pilot](https://app.notion.com/p/4032db0e8691818496f5c235840c351c). +Release gates and implementation sequence: [integration notes](../../docs/supabase-auth-integration.md). + +## Implemented + +- Standard Webhooks verification using the pinned upstream library, on the exact + raw body before JSON parsing; timestamp checks and bounded input size. +- One current and optionally one previous signing secret for rotation. Accepts + Supabase's `v1,whsec_…` and Standard Webhooks' `whsec_…` forms; requires at least + 32 decoded key bytes. +- Server-configured project, workspace, optional client, sender, branding and + exact callback allowlist. Signed user metadata cannot select these values. +- Recipient-specific idempotency keys, stable across re-signed retries. The + key includes project/workspace/client scope, event ID, action and recipient + role/address, but no OTP, token hash, body digest or timestamp. +- Explicit action matrix and safe fixed errors, with no raw validation errors + or library exception causes crossing the boundary. +- Redacted default JSON/string/Node inspection of prepared email objects. + Explicit content access remains sensitive; never log or persist that result. + +## Action matrix + +| Supabase action | Recipients | Credentials used | +| --- | --- | --- | +| `signup` | `user.email` | `token`, `token_hash` | +| `magiclink` | `user.email` | `token`, `token_hash` | +| `recovery` | `user.email` | `token`, `token_hash` | +| `invite` | `user.email` | `token`, `token_hash` | +| `reauthentication` | `user.email` | Code only; no verification link | +| `email_change`, secure mode | Current and new address | Current: `token` + **`token_hash_new`**; new: `token_new` + **`token_hash`** | +| `email_change`, single mode | `user.new_email` only | Exactly one of `token`/`token_new`, plus `token_hash` | + +The explicit `emailChangeMode` must match the Supabase project. Missing halves +of a secure change are rejected before a plan is returned. Other notification +actions are rejected until deliberately implemented. Supabase issues and verifies +credentials; this package never mints a token or declares a session valid. + +For link actions the verification host comes from trusted `supabaseUrl`, never +payload `site_url`. `redirect_to` must exactly match a server allowlist entry; +an absent/empty value uses the configured default. Explicit native-app callbacks +are supported as strings, but native app session completion is not tested here. +Plain HTTP callbacks are deliberately not supported in this first increment. + +## Local development (synthetic fixtures only) + +```sh +pnpm --filter @rootmail/supabase typecheck +pnpm --filter @rootmail/supabase test +``` + +Package tests use an independent HMAC signer, synthetic secrets, a fake ledger and +injected fetch; they never make real network/mail calls. Separate API tests use +the real local database, queue, worker and PostgreSQL ledger with fake delivery. +Turbo discovers both suites. + +`createSupabaseEmailPlanner(config)` returns a function accepting unmodified +request text and `Headers` (or a header record). Its result has `messages`, each +with a stable `idempotencyKey` and explicit `readSensitiveContent()` accessor. +Keep this module server-side. Do not serialize plans into application logs, +tracing/APM, request recordings, databases or Notion, even though common accidental +inspection paths are redacted. Do not use production payloads as test fixtures. + +### What these tests do not prove + +Stable keys are **not exactly-once delivery**. The HTTP wrapper tests now cover +partial-recipient retry, lost responses, bounded upload/network/ledger work, +tenant/contract verification and provider-acceptance polling. These are not live +latency or Supabase/native-session evidence. A signature timestamp is not proof +of code issuance. The frozen first-event deadline reserves five minutes from the +configured 10–60 minute OTP lifetime; it does not prove a code remains valid after +a resend or before a delayed first observation. Never renew TTL on retry. + +## Setup, rotation and rollback + +Use the [setup reference](../../docs/supabase-hook-setup.md) only after its release +gates pass. No production hook is configured by this branch: + +1. Review and validate the auth-only Rootmail content/expiry contract. The + transport preflights policy/provider/workspace/client before posting secrets; + do not send invented privacy fields to `/v1/messages` (unknown fields are + currently stripped, not enforced). +2. Configure one customer's project, workspace/client-scoped key, verified + sender, branding, callback allowlist and secure-email-change setting. + Keep both keys in the server secret store; never in browser/native bundles. + Current client-scoped Rootmail keys are not endpoint-limited send-only keys. +3. Mount `createSupabaseEmailHook` on one raw-body HTTPS route with the durable + ledger. Its shared 4.5-second budget includes streaming, verification, database + work and both recipients. It polls queued work and only acknowledges observed + SES acceptance for every recipient. Never log request/response bodies. Exempt + only this hook route from user-session/JWT middleware; keep signature checking. +4. Test confirmed recipients and failure paths using mocks first. Separately + authorize a real Sprout sign-in and verify delivery and native session. +5. Only after those gates: record existing Supabase email settings, enable the + hook on a test project, then perform the controlled customer cutover. + +Rotate hook secrets with a short two-key overlap, exercise both, then remove the +old secret and confirm rejection. Future API-key rotation must exercise the new +key before revoking the old one; revocation must fail safely rather than fall back +to a different workspace/provider. Never put either secret in a ticket. + +Rollback will disable the Send Email Hook and restore the previously tested +Supabase email configuration. Disabling a hook is not proof that the fallback +SMTP provider can send; verify that independently before rollout. This increment +has changed no Supabase setting, so no external rollback is currently necessary. + +## References + +- [Supabase Send Email Hook and email-change mapping](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook) +- [Supabase hook errors and time budget](https://supabase.com/docs/guides/auth/auth-hooks) +- [Standard Webhooks implementation](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/javascript) + +Configs touched: package metadata; reference customer-ledger SQL; disabled server +settings and migrations 0083/0084; CI-only vault fixture. Prod behaviour preserved: +no running app mounts this hook and auth remains off by default. No production +deployment, migration, environment, IAM, DNS or Supabase setting was changed. diff --git a/packages/supabase/examples/ledger.sql b/packages/supabase/examples/ledger.sql new file mode 100644 index 00000000..48f76346 --- /dev/null +++ b/packages/supabase/examples/ledger.sql @@ -0,0 +1,15 @@ +-- Customer hook-host database, NOT a Rootmail production migration. +-- Apply with a migration/admin role; do not grant this schema to browser roles. +CREATE SCHEMA IF NOT EXISTS rootmail_auth_hook; +REVOKE ALL ON SCHEMA rootmail_auth_hook FROM PUBLIC; +CREATE TABLE IF NOT EXISTS rootmail_auth_hook.events ( + event_key text PRIMARY KEY CHECK (event_key ~ '^[a-f0-9]{64}$'), + fingerprint text NOT NULL CHECK (fingerprint ~ '^[a-f0-9]{64}$'), + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +REVOKE ALL ON rootmail_auth_hook.events FROM PUBLIC; +-- Grant only USAGE on this schema and SELECT, INSERT, UPDATE on this table to +-- the dedicated hook-server role. Keep credentials out of client bundles. +-- Keep expired rows as replay tombstones; do not prune/reset them automatically. +-- No email addresses, bodies, codes, URLs or unkeyed content digests are stored. diff --git a/packages/supabase/package.json b/packages/supabase/package.json new file mode 100644 index 00000000..bc4bb650 --- /dev/null +++ b/packages/supabase/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rootmail/supabase", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Unreleased Supabase auth-email planner, bounded HTTP hook and durable replay ledger.", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test \"src/**/*.test.ts\"" + }, + "dependencies": { + "standardwebhooks": "1.1.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/supabase/src/hook.test.ts b/packages/supabase/src/hook.test.ts new file mode 100644 index 00000000..c0f657db --- /dev/null +++ b/packages/supabase/src/hook.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { test } from "node:test"; +import { createSupabaseEmailHook, type SupabaseHookConfig, type HookLedger } from "./index"; + +const secret = Buffer.alloc(32, 11); +const fixture = (action = "signup") => ({ user: { email: "one@example.test", new_email: "two@example.test" }, + email_data: { email_action_type: action, token: "123456", token_hash: "fixture-hash", + ...(action === "email_change" ? { token_new: "654321", token_hash_new: "other-fixture-hash" } : {}) } }); +function request(payload = fixture(), id = "evt_fixture", seconds = Math.floor(Date.now() / 1000)) { + const raw = JSON.stringify(payload); + return new Request("https://hook.example.test", { method: "POST", headers: { "content-type": "application/json", + "webhook-id": id, "webhook-timestamp": String(seconds), + "webhook-signature": `v1,${createHmac("sha256", secret).update(`${id}.${seconds}.${raw}`).digest("base64")}` }, body: raw }); +} +function setup() { + const entries = new Map(); + const ledger: HookLedger = { async claim(input) { + if (!entries.has(input.eventKey)) entries.set(input.eventKey, { fingerprint: input.fingerprint, expiresAt: input.expiresAt }); + return entries.get(input.eventKey)!; + } }; + const config: SupabaseHookConfig = { hookSecrets: [`whsec_${secret.toString("base64")}`], + supabaseUrl: "https://project.example.test", workspaceId: "ws_test", clientId: "tnt_test", + from: "auth@example.test", brand: "Example", emailChangeMode: "secure", + allowedRedirects: ["exampleapp://auth/callback"], defaultRedirect: "exampleapp://auth/callback", + rootmailUrl: "https://api.example.test", rootmailApiKey: "rm_live_fixture_only", + credentialLifetimeSeconds: 600, ledgerKey: Buffer.alloc(32, 12).toString("base64"), ledger, budgetMs: 500 }; + const rows = new Map>(); + const posts: Record[] = []; + let sends = 0; + const fakeFetch: typeof fetch = async (url, options) => { + assert.equal(options?.redirect, "error"); + assert.equal((options?.headers as Record)["x-rootmail-subtenant"], "tnt_test"); + const path = new URL(String(url)).pathname; + if (path.endsWith("capabilities")) return Response.json({ policy: "authentication-v1", provider: "ses", workspace_id: "ws_test", sub_tenant_id: "tnt_test" }); + if (options?.method === "POST") { + const body = JSON.parse(String(options.body)); posts.push(body); + if (!rows.has(body.idempotency_key)) { + sends++; + rows.set(body.idempotency_key, { id: `msg_fixture${sends}`, content_policy: body.policy, + workspace_id: "ws_test", sub_tenant_id: "tnt_test", expires_at: body.expires_at, to: body.to, + sandbox: false, test_recipient: null, status: "sent", provider: "ses", provider_message_id: `fake_${sends}` }); + } + return Response.json(rows.get(body.idempotency_key)); + } + return Response.json([...rows.values()].find(row => path.endsWith(String(row.id)))); + }; + return { config, entries, rows, posts, fakeFetch, sends: () => sends }; +} + +test("hook verifies before any external work and never echoes secrets", async () => { + const f = setup(); let calls = 0; + const hook = createSupabaseEmailHook(f.config, { fetch: async () => { calls++; throw Error("secret-code"); } }); + const req = request(); req.headers.set("webhook-signature", "v1,invalid"); + const response = await hook(req); + assert.equal(response.status, 401); assert.equal(calls, 0); assert.equal(f.entries.size, 0); + assert.ok(!(await response.text()).includes("123456")); +}); + +test("native code-only mode sends the eight-digit OTP without callback URLs or token hashes", async () => { + const f = setup(); + const { allowedRedirects: _allowed, defaultRedirect: _default, ...base } = f.config; + const hook = createSupabaseEmailHook({ ...base, deliveryMode: "code_only" }, { fetch: f.fakeFetch }); + const payload = { ...fixture(), email_data: { ...fixture().email_data, + token: "12345678", redirect_to: "http://localhost:3000", site_url: "https://untrusted.example" } }; + assert.equal((await hook(request(payload))).status, 200); + assert.equal(f.posts.length, 1); + for (const body of [String(f.posts[0].html), String(f.posts[0].text)]) { + assert.ok(body.includes("12345678")); + for (const forbidden of ["localhost", "untrusted.example", "fixture-hash", "/auth/v1/verify", " createSupabaseEmailHook(base, { fetch: f.fakeFetch }), /invalid_configuration/); +}); + +test("pilot recipient restriction rejects every recipient before network or ledger work", async () => { + const f = setup(); let calls = 0; + const recipients = ["ONE@example.test"]; + const hook = createSupabaseEmailHook({ ...f.config, allowedRecipients: recipients }, { fetch: async (...args) => { calls++; return f.fakeFetch(...args); } }); + recipients.push("two@example.test"); // configuration was copied, not shared + assert.equal((await hook(request(fixture("email_change")))).status, 403); + assert.equal(calls, 0); assert.equal(f.entries.size, 0); + assert.equal((await hook(request())).status, 200); + assert.throws(() => createSupabaseEmailHook({ ...f.config, allowedRecipients: [] }), /invalid_configuration/); +}); + +test("re-signed retry and restarted hook preserve original expiry and recipient IDs", async () => { + const f = setup(); + const now = Math.floor(Date.now() / 1000); + assert.equal((await createSupabaseEmailHook(f.config, { fetch: f.fakeFetch })(request(fixture(), "evt", now - 20))).status, 200); + assert.equal((await createSupabaseEmailHook(f.config, { fetch: f.fakeFetch })(request(fixture(), "evt", now))).status, 200); + assert.equal(f.sends(), 1); + assert.equal(f.posts[0].expires_at, f.posts[1].expires_at); + assert.equal(f.posts[0].idempotency_key, f.posts[1].idempotency_key); + assert.ok(!JSON.stringify([...f.entries]).includes("123456")); + const changed = fixture(); changed.email_data.token = "999999"; + assert.equal((await createSupabaseEmailHook(f.config, { fetch: f.fakeFetch })(request(changed, "evt"))).status, 409); + assert.equal(f.posts.length, 2); +}); + +test("partial two-recipient completion retries the missing recipient without resending the first", async () => { + const f = setup(); let failSecond = true; + const fetcher: typeof fetch = async (url, opts) => { + if (opts?.method === "POST" && JSON.parse(String(opts.body)).to === "two@example.test" && failSecond) return new Response("private-provider-error", { status: 503 }); + return f.fakeFetch(url, opts); + }; + const hook = createSupabaseEmailHook(f.config, { fetch: fetcher }); + assert.equal((await hook(request(fixture("email_change")))).status, 503); + assert.equal(f.sends(), 1); + failSecond = false; + assert.equal((await hook(request(fixture("email_change")))).status, 200); + assert.equal(f.sends(), 2); + assert.equal(f.entries.size, 1); +}); + +test("ambiguous POST response is recovered only by the same idempotent request", async () => { + const f = setup(); let drop = true; + const hook = createSupabaseEmailHook(f.config, { fetch: async (url, opts) => { + const response = await f.fakeFetch(url, opts); + if (opts?.method === "POST" && drop) { drop = false; throw Error("secret-provider-response"); } + return response; + } }); + assert.equal((await hook(request())).status, 503); + assert.equal((await hook(request())).status, 200); + assert.equal(f.sends(), 1); +}); + +test("expired ledger tombstones cannot be renewed by a newly signed retry", async () => { + const f = setup(); + f.config.ledger = { async claim(input) { return { fingerprint: input.fingerprint, expiresAt: new Date(Date.now() - 1).toISOString() }; } }; + assert.equal((await createSupabaseEmailHook(f.config, { fetch: f.fakeFetch })(request())).status, 422); + assert.equal(f.posts.length, 0); +}); + +test("unsupported server, wrong scope and mock capability are refused before secrets are posted", async () => { + for (const capability of [null, { policy: "authentication-v1", provider: "mock", workspace_id: "ws_test", sub_tenant_id: "tnt_test" }, + { policy: "authentication-v1", provider: "ses", workspace_id: "ws_other", sub_tenant_id: "tnt_test" }]) { + const f = setup(); let calls = 0; + const hook = createSupabaseEmailHook(f.config, { fetch: async (_url, opts) => { + calls++; assert.equal(opts?.method, "GET"); return capability ? Response.json(capability) : new Response("", { status: 404 }); + } }); + assert.equal((await hook(request())).status, 503); assert.equal(calls, 1); assert.equal(f.entries.size, 0); + } +}); + +test("one total deadline bounds stalled response, ledger and upload streams", async () => { + for (const stalled of ["fetch", "ledger", "upload", "response"]) { + const f = setup(); f.config.budgetMs = 50; + if (stalled === "ledger") f.config.ledger = { claim: () => new Promise(() => {}) }; + const hook = createSupabaseEmailHook(f.config, { fetch: stalled === "fetch" ? () => new Promise(() => {}) + : stalled === "response" ? async () => new Response(new ReadableStream({ start() {} })) : f.fakeFetch }); + const req = stalled === "upload" ? new Request("https://hook.example.test", { method: "POST", headers: { "content-type": "application/json" }, + body: new ReadableStream({ start() {} }), duplex: "half" } as RequestInit) : request(); + const started = Date.now(); + assert.equal((await hook(req)).status, 503); + assert.ok(Date.now() - started < 1000); + assert.equal(f.posts.length, 0); + } +}); + +test("queued is not success; polling requires real provider acceptance", async () => { + for (const state of ["sent", "queued", "failed", "suppressed", "mock", "wrong-scope"]) { + const f = setup(); f.config.budgetMs = 160; + const hook = createSupabaseEmailHook(f.config, { fetch: async (url, opts) => { + const response = await f.fakeFetch(url, opts); + if (String(url).endsWith("capabilities")) return response; + const row = await response.json(); + if (opts?.method === "POST") row.status = "queued"; + else { + row.status = ["mock", "wrong-scope"].includes(state) ? "sent" : state; + if (state === "mock") row.provider = "mock"; + if (state === "wrong-scope") row.sub_tenant_id = "tnt_other"; + } + return Response.json(row); + } }); + const response = await hook(request()); + assert.equal(response.status === 200, state === "sent", state); + } +}); + +test("rejects method, oversized streams, bad content type and malformed configuration", async () => { + const f = setup(); const hook = createSupabaseEmailHook(f.config, { fetch: f.fakeFetch }); + assert.equal((await hook(new Request("https://hook.example.test"))).status, 405); + assert.equal((await hook(new Request("https://hook.example.test", { method: "POST", body: "{}" }))).status, 415); + assert.equal((await hook(new Request("https://hook.example.test", { method: "POST", headers: { "content-type": "application/json" }, body: "a".repeat(128 * 1024 + 1) }))).status, 413); + for (const patch of [{ rootmailUrl: "http://example.test" }, { rootmailUrl: "https://user:secret@example.test" }, { budgetMs: 5000 }, { credentialLifetimeSeconds: 10 }, { rootmailApiKey: "rm_test_fixture" }]) { + assert.throws(() => createSupabaseEmailHook({ ...f.config, ...patch }), /invalid_configuration/); + } +}); + +test("revocation, suppression, quota and rate failures remain safe errors with no adapter POST retries", async () => { + for (const status of [401, 402, 403, 409, 422, 429, 500]) { + const f = setup(); let posts = 0; + const hook = createSupabaseEmailHook(f.config, { fetch: async (url, opts) => { + if (opts?.method !== "POST") return f.fakeFetch(url, opts); + posts++; return new Response("echoed-secret-123456", { status }); + } }); + const result = await hook(request()); + assert.equal(result.status, status === 500 ? 503 : status); + assert.ok(!(await result.text()).includes("123456")); + assert.equal(posts, 1); + } +}); + +test("pre-aborted requests and oversized upstream responses fail closed", async () => { + const f = setup(); let calls = 0; + const hook = createSupabaseEmailHook(f.config, { fetch: async () => { + calls++; return new Response("x".repeat(64 * 1024 + 1)); + } }); + const controller = new AbortController(); controller.abort(); + const aborted = new Request(request(), { signal: controller.signal }); + assert.equal((await hook(aborted)).status, 503); assert.equal(calls, 0); + assert.notEqual((await hook(request())).status, 200); assert.equal(calls, 1); + assert.equal(f.entries.size, 0); +}); diff --git a/packages/supabase/src/hook.ts b/packages/supabase/src/hook.ts new file mode 100644 index 00000000..a37b6e9b --- /dev/null +++ b/packages/supabase/src/hook.ts @@ -0,0 +1,183 @@ +import { createHmac } from "node:crypto"; +import { z } from "zod"; +import { createSupabaseEmailPlanner, SupabaseHookError, type SupabaseEmailConfig } from "./index"; + +export interface HookLedger { + /** Atomic insert-or-read, immutable expiry/commitment. Must be durable across hosts/restarts. */ + claim(input: { eventKey: string; fingerprint: string; expiresAt: string }): Promise<{ fingerprint: string; expiresAt: string }>; +} +export interface SupabaseHookConfig extends SupabaseEmailConfig { + rootmailUrl: string; + rootmailApiKey: string; + replyTo?: string; + /** Optional controlled-pilot restriction, checked after signature verification. */ + allowedRecipients?: readonly string[]; + /** Match the project's OTP TTL; currently supports 10–60 minutes with a 5-minute reserve. */ + credentialLifetimeSeconds: number; + /** Independent stable 32-byte base64 secret. Rotation is not transparent to existing events. */ + ledgerKey: string; + ledger: HookLedger; + budgetMs?: number; +} +class HookFailure extends Error { + constructor(readonly status: number) { super("authentication_email_unconfirmed"); } +} +function rootmailUrl(value: string): URL { + try { return new URL(value); } + catch { throw new SupabaseHookError("invalid_configuration"); } +} +function pause(signal: AbortSignal, milliseconds: number): Promise { + return new Promise(resolve => { + const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); }; + const timer = setTimeout(finish, milliseconds); + signal.addEventListener("abort", finish, { once: true }); + if (signal.aborted) finish(); + }); +} + +/** Race as well as abort: broken adapters/streams must not hold a hook response open. */ +function bounded(operation: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(new HookFailure(503)); + if (signal.aborted) { void operation.catch(() => {}); abort(); return; } + signal.addEventListener("abort", abort, { once: true }); + operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); +} +async function readLimited(body: ReadableStream | null, limit: number, signal: AbortSignal): Promise { + if (!body) throw new HookFailure(400); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await bounded(reader.read(), signal); + if (done) break; + size += value.byteLength; + if (size > limit) throw new HookFailure(413); + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } finally { void reader.cancel().catch(() => {}); } +} +const receipt = z.object({ + id: z.string().regex(/^msg_[A-Za-z0-9_-]+$/), content_policy: z.literal("authentication-v1"), + workspace_id: z.string(), sub_tenant_id: z.string().nullable(), to: z.string(), + expires_at: z.string().datetime(), sandbox: z.literal(false), test_recipient: z.null(), + status: z.string(), provider: z.string().nullable(), provider_message_id: z.string().nullable(), +}); + +/** Web Request -> Response reference hook. No runtime app installs it automatically. */ +export function createSupabaseEmailHook(input: SupabaseHookConfig, dependencies: { fetch?: typeof fetch } = {}) { + const planner = createSupabaseEmailPlanner(input); + const url = rootmailUrl(input.rootmailUrl); + const key = Buffer.from(typeof input.ledgerKey === "string" ? input.ledgerKey : "", "base64"); + const budget = input.budgetMs ?? 4500; + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || + !/^rm_live_[A-Za-z0-9_-]+$/.test(input.rootmailApiKey) || key.length !== 32 || key.toString("base64") !== input.ledgerKey || + !Number.isInteger(input.credentialLifetimeSeconds) || input.credentialLifetimeSeconds < 600 || input.credentialLifetimeSeconds > 3600 || + !Number.isInteger(budget) || budget < 50 || budget > 4500 || typeof input.ledger?.claim !== "function" || + (input.replyTo !== undefined && (!z.string().email().safeParse(input.replyTo).success || input.replyTo.toLowerCase().startsWith("reply+")))) { + throw new SupabaseHookError("invalid_configuration"); + } + if (input.allowedRecipients !== undefined && !z.array(z.string().email()).min(1).max(10).safeParse(input.allowedRecipients).success) { + throw new SupabaseHookError("invalid_configuration"); + } + const allowedRecipients = input.allowedRecipients ? new Set(input.allowedRecipients.map(email => email.toLowerCase())) : null; + // Copy mutable caller config once. Per-request tenant claims never affect these values. + const { rootmailApiKey, replyTo, credentialLifetimeSeconds, ledger } = input; + const fetcher = dependencies.fetch ?? fetch; + return async (request: Request): Promise => { + const started = performance.now(); + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = setTimeout(abort, budget); + request.signal.addEventListener("abort", abort, { once: true }); + if (request.signal.aborted) abort(); + const signal = controller.signal; + const checkBudget = () => { + if (signal.aborted || performance.now() - started >= budget) throw new HookFailure(503); + }; + try { + if (request.method !== "POST") throw new HookFailure(405); + if (!/^application\/json(?:\s*;|$)/i.test(request.headers.get("content-type") ?? "")) throw new HookFailure(415); + const length = request.headers.get("content-length"); + if (length && (!/^\d+$/.test(length) || Number(length) > 128 * 1024)) throw new HookFailure(413); + const raw = await readLimited(request.body, 128 * 1024, signal); + const plan = planner(raw, request.headers); // verifies before any ledger or network operation + if (allowedRecipients && plan.messages.some(message => !allowedRecipients.has(message.readSensitiveContent().to.toLowerCase()))) { + throw new HookFailure(403); + } + const headers = { authorization: `Bearer ${rootmailApiKey}`, "content-type": "application/json", + ...(plan.clientId ? { "x-rootmail-subtenant": plan.clientId } : {}) }; + const api = async (path: string, method = "GET", body?: unknown): Promise => { + checkBudget(); + const init = { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + redirect: "error" as const, cache: "no-store" as const, signal, + }; + const response = await bounded(fetcher(new URL(path, url), init), signal); + if (!response.ok) { + void response.body?.cancel().catch(() => {}); + throw new HookFailure([401, 402, 403, 409, 422, 429].includes(response.status) ? response.status : 503); + } + return JSON.parse(await readLimited(response.body, 64 * 1024, signal)); + }; + // Old servers must reject BEFORE receiving secret content. Never fall back to ordinary mail. + const capability = z.object({ policy: z.literal("authentication-v1"), provider: z.literal("ses"), + workspace_id: z.literal(plan.workspaceId), sub_tenant_id: z.literal(plan.clientId) }).safeParse( + await api("/v1/auth-messages/capabilities")); + if (!capability.success) throw new HookFailure(503); + const fingerprint = createHmac("sha256", key).update("rootmail-hook-content-v1\0").update(JSON.stringify([ + plan.action, plan.from, replyTo ?? null, credentialLifetimeSeconds, + plan.messages.map(m => [m.idempotencyKey, m.readSensitiveContent()]), + ])).digest("hex"); + // Timestamp is a signature time, NOT proven issuance. Reserve the whole allowed signature + // age (5 minutes), then persist this first-event bound. Re-signing never renews it. + const proposed = Math.min(Date.now(), plan.signedAt) + (credentialLifetimeSeconds - 300) * 1000; + checkBudget(); + const entry = await bounded(ledger.claim({ eventKey: plan.eventKey, fingerprint, expiresAt: new Date(proposed).toISOString() }), signal); + if (entry.fingerprint !== fingerprint) throw new HookFailure(409); + const expiresAt = new Date(entry.expiresAt); + if (!Number.isFinite(+expiresAt) || +expiresAt > proposed || +expiresAt - Date.now() <= 15_000) throw new HookFailure(422); + const deadline = expiresAt.toISOString(); + // Both recipients share the single request budget; no nested POST retry loop. + await Promise.all(plan.messages.map(async message => { + const content = message.readSensitiveContent(); + let result = await api("/v1/auth-messages", "POST", { policy: "authentication-v1", to: content.to, + from: plan.from, ...(replyTo ? { reply_to: replyTo } : {}), html: content.html, text: content.text, + expires_at: deadline, idempotency_key: message.idempotencyKey }); + let id: string | undefined; + let pollDelay = 100; + for (;;) { + const parsed = receipt.safeParse(result); + if (!parsed.success) throw new HookFailure(503); + const row = parsed.data; + if (row.workspace_id !== plan.workspaceId || row.sub_tenant_id !== plan.clientId || row.to !== content.to || + row.expires_at !== deadline || (id && row.id !== id)) throw new HookFailure(503); + id = row.id; + if (["sent", "delivered"].includes(row.status) && row.provider === "ses" && row.provider_message_id) return; + if (!["queued", "sending"].includes(row.status)) throw new HookFailure(409); + if (+expiresAt - Date.now() <= 15_000) throw new HookFailure(422); + await bounded(pause(signal, pollDelay), signal); + pollDelay = Math.min(400, pollDelay * 2); + result = await api(`/v1/messages/${id}`); + } + })); + checkBudget(); + return Response.json({}, { status: 200, headers: { "cache-control": "no-store" } }); + } catch (error) { + const status = error instanceof HookFailure ? error.status : error instanceof SupabaseHookError + ? error.code === "invalid_signature" ? 401 : 422 : 503; + return Response.json({ error: { http_code: status, message: "Authentication email could not be confirmed. Request a new code if needed." } }, + { status, headers: { "cache-control": "no-store" } }); + } finally { + clearTimeout(timer); + request.signal.removeEventListener("abort", abort); + controller.abort(); // cancel sibling recipient work after partial failure + } + }; +} diff --git a/packages/supabase/src/index.test.ts b/packages/supabase/src/index.test.ts new file mode 100644 index 00000000..fafeb300 --- /dev/null +++ b/packages/supabase/src/index.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { inspect } from "node:util"; +import { test } from "node:test"; +import { createSupabaseEmailPlanner, SupabaseHookError, type HookErrorCode, type SupabaseEmailConfig } from "./index"; + +// Synthetic fixtures only. This package has no mail/network/DB dependency. +const key = Buffer.alloc(32, 7); +const previousKey = Buffer.alloc(32, 8); +const secret = `v1,whsec_${key.toString("base64")}`; +const config: SupabaseEmailConfig & { allowedRedirects: string[]; defaultRedirect: string } = { + hookSecrets: [secret], supabaseUrl: "https://project.example.com", + workspaceId: "ws_fixture", clientId: "st_fixture", from: "auth@example.com", + brand: "Example App", emailChangeMode: "secure", + allowedRedirects: ["https://app.example.com/callback", "exampleapp://auth/callback"], + defaultRedirect: "https://app.example.com/callback", +}; +const fixture = () => ({ + user: { email: "current@example.com", new_email: "next@example.com" }, + email_data: { + email_action_type: "signup", token: "123456", token_hash: "fixture-hash-current", + token_new: "", token_hash_new: "", redirect_to: config.defaultRedirect, + site_url: "https://untrusted.example.org", + }, +}); +function signed(raw: string, options: { id?: string; seconds?: number; signingKey?: Buffer } = {}) { + const id = options.id ?? "evt_fixture"; + const seconds = options.seconds ?? Math.floor(Date.now() / 1000); + const signature = createHmac("sha256", options.signingKey ?? key) + .update(`${id}.${seconds}.${raw}`).digest("base64"); + return { "webhook-id": id, "webhook-timestamp": String(seconds), "webhook-signature": `v1,${signature}` }; +} +const plan = createSupabaseEmailPlanner(config); +function prepare(payload = fixture(), settings = config, eventId?: string) { + const raw = JSON.stringify(payload); + return createSupabaseEmailPlanner(settings)(raw, signed(raw, { id: eventId })); +} +function fails(code: HookErrorCode, fn: () => unknown) { + assert.throws(fn, (error: unknown) => { + assert.ok(error instanceof SupabaseHookError); + assert.equal(error.code, code); + assert.equal(error.message, `Supabase email hook: ${code}`); + assert.equal(error.cause, undefined); + return true; + }); +} + +for (const action of ["signup", "magiclink", "recovery", "invite", "reauthentication"]) { + test(`prepares ${action} without claiming a send`, () => { + const payload = fixture(); + payload.email_data.email_action_type = action; + if (action === "reauthentication") payload.email_data.token_hash = ""; + const result = prepare(payload); + assert.equal(result.action, action); + assert.equal(result.messages.length, 1); + assert.equal("status" in result, false); + const message = result.messages[0].readSensitiveContent(); + assert.equal(message.to, payload.user.email); + assert.ok(message.text.includes(payload.email_data.token)); + if (action === "reauthentication") { + assert.equal(message.verificationUrl, null); + assert.ok(!message.html.includes("href=")); + } else { + const url = new URL(message.verificationUrl!); + assert.equal(url.origin, config.supabaseUrl); + assert.equal(url.pathname, "/auth/v1/verify"); + assert.equal(url.searchParams.get("type"), action); + assert.equal(url.searchParams.get("token"), payload.email_data.token_hash); + assert.equal(url.searchParams.get("redirect_to"), config.defaultRedirect); + } + }); +} + +test("verifies exact raw bytes before parsing", () => { + const raw = JSON.stringify(fixture(), null, 2); + assert.equal(plan(raw, signed(raw)).messages.length, 1); + fails("invalid_signature", () => plan(JSON.stringify(fixture()), signed(raw))); + fails("invalid_signature", () => plan("invalid-json-with-secret", signed(raw))); + fails("invalid_payload", () => plan("invalid-json-with-secret", signed("invalid-json-with-secret"))); +}); + +for (const offset of [-601, 601]) { + test(`rejects correctly signed timestamp offset ${offset}`, () => { + const raw = JSON.stringify(fixture()); + fails("invalid_signature", () => plan(raw, signed(raw, { seconds: Math.floor(Date.now() / 1000) + offset }))); + }); +} + +test("rejects missing, malformed, combined and duplicate signature headers", () => { + const raw = JSON.stringify(fixture()); + for (const name of ["webhook-id", "webhook-timestamp", "webhook-signature"]) { + fails("invalid_signature", () => plan(raw, { ...signed(raw), [name]: "" })); + } + const valid = signed(raw); + for (const timestamp of [valid["webhook-timestamp"] + "junk", "NaN", "1", valid["webhook-timestamp"] + ", 1"]) { + fails("invalid_signature", () => plan(raw, { ...valid, "webhook-timestamp": timestamp })); + } + fails("invalid_signature", () => plan(raw, { ...valid, "Webhook-Id": "different" })); + fails("invalid_signature", () => plan(raw, { ...valid, "webhook-id": "first, second" })); + fails("invalid_signature", () => plan(raw, { ...valid, "webhook-signature": "x".repeat(4097) })); +}); + +test("accepts case-insensitive Headers and multiple signatures", () => { + const raw = JSON.stringify(fixture()); + const headers = new Headers(); + for (const [name, value] of Object.entries(signed(raw))) headers.set(name.toUpperCase(), value); + headers.set("webhook-signature", `v1,invalid ${headers.get("webhook-signature")}`); + assert.equal(plan(raw, headers).messages.length, 1); +}); + +test("key rotation overlap works and removed keys stop verifying", () => { + const raw = JSON.stringify(fixture()); + const oldHeaders = signed(raw, { signingKey: previousKey }); + const rotating = createSupabaseEmailPlanner({ ...config, hookSecrets: [secret, `whsec_${previousKey.toString("base64")}`] }); + assert.equal(rotating(raw, oldHeaders).messages.length, 1); + assert.equal(rotating(raw, signed(raw)).messages.length, 1); + fails("invalid_signature", () => plan(raw, oldHeaders)); +}); + +test("rejects unsupported and malformed signed payloads with fixed safe errors", () => { + for (const bad of [null, {}, { user: fixture().user }, { ...fixture(), user: { email: "not-email" } }]) { + const raw = JSON.stringify(bad); + fails("invalid_payload", () => plan(raw, signed(raw))); + } + const unsupported = fixture(); + unsupported.email_data.email_action_type = "password_changed_notification"; + fails("unsupported_action", () => prepare(unsupported)); + const missing = fixture(); + missing.email_data.token_hash = ""; + fails("invalid_payload", () => prepare(missing)); + const injected = fixture(); + injected.email_data.token = "123456