From c38ba01d0814857bbe05d5ad69b6f2d8deda66c2 Mon Sep 17 00:00:00 2001 From: Odinaka Robert Nnamani Date: Sun, 25 Oct 2026 05:04:25 +0100 Subject: [PATCH 1/5] feat(supabase): add signed auth-email planner foundation --- docs/COLLAB.md | 14 ++ docs/supabase-auth-integration.md | 107 +++++++++++ packages/supabase/README.md | 118 ++++++++++++ packages/supabase/package.json | 20 ++ packages/supabase/src/index.test.ts | 271 ++++++++++++++++++++++++++++ packages/supabase/src/index.ts | 219 ++++++++++++++++++++++ packages/supabase/tsconfig.json | 5 + pnpm-lock.yaml | 24 +++ 8 files changed, 778 insertions(+) create mode 100644 docs/supabase-auth-integration.md create mode 100644 packages/supabase/README.md create mode 100644 packages/supabase/package.json create mode 100644 packages/supabase/src/index.test.ts create mode 100644 packages/supabase/src/index.ts create mode 100644 packages/supabase/tsconfig.json diff --git a/docs/COLLAB.md b/docs/COLLAB.md index 966289c..3e18482 100644 --- a/docs/COLLAB.md +++ b/docs/COLLAB.md @@ -303,3 +303,17 @@ 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. diff --git a/docs/supabase-auth-integration.md b/docs/supabase-auth-integration.md new file mode 100644 index 0000000..a178330 --- /dev/null +++ b/docs/supabase-auth-integration.md @@ -0,0 +1,107 @@ +# 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). + +## Current increment + +`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. + +## Required next increment: authentication content policy + +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 acceptance boxes remain open. + +Configs touched: new private package manifest/TypeScript configuration and lockfile. +Prod behaviour preserved: no runtime import, migration, environment change, +deployment, live email, production key creation or Supabase configuration update. diff --git a/packages/supabase/README.md b/packages/supabase/README.md new file mode 100644 index 0000000..091070e --- /dev/null +++ b/packages/supabase/README.md @@ -0,0 +1,118 @@ +# Supabase email adapter — implementation foundation + +**Unreleased, private, and not connected to sending. Do not enable this as a +production Supabase hook yet.** This increment prepares authenticated email +plans in memory; it does not call Rootmail, return hook success, or create a +Supabase session. The ordinary Rootmail send endpoint currently retains bodies +and conversation copies. Sending auth secrets through it is not this adapter's +supported integration path. + +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 +``` + +The tests use an independent HMAC signer with synthetic secrets and recipients. +They do not import Rootmail environment, database, queue or provider code. The +workspace's existing unit-test job discovers this package through Turbo. + +`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**. No HTTP transport, durable replay +ledger, concurrent duplicate admission, partial-send recovery, tenant-key +authorization, provider failure/timeout handling or content expiry is implemented +here. A signature timestamp is an anti-replay window, not the code's issuance or +expiry time. Never derive a fresh TTL on every retry. + +## Planned setup, rotation and rollback + +This is a checklist for the next increment, **not a deployable customer guide**: + +1. Implement and test the auth-only Rootmail content/expiry contract before + adding a network transport. It must fail closed against servers lacking it; + 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. Add a raw-body HTTPS hook wrapper, stream size/time limits, safe status mapping + and a total request deadline within Supabase's budget. No raw request/response + logging. Disable platform JWT checks only on that hook route, and enforce the + webhook signature there instead. Do not disable application authentication. +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: this private package's manifest/TypeScript config and the +workspace lockfile. Prod behaviour preserved: no running app imports this package; +no send, queue, database, tracking, provider, IAM, DNS or Supabase setting changed. diff --git a/packages/supabase/package.json b/packages/supabase/package.json new file mode 100644 index 0000000..cac55bb --- /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 authentication-email adapter foundation; no live transport.", + "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/index.test.ts b/packages/supabase/src/index.test.ts new file mode 100644 index 0000000..d02c66e --- /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 = { + 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