Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions apps/api/src/cli/supabase-hook-server.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
});
45 changes: 45 additions & 0 deletions apps/api/src/lib/auth-hook-http.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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<number | undefined>((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<void>(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<void>(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<void>(resolve => server.close(() => resolve())); }
});
50 changes: 50 additions & 0 deletions apps/api/src/lib/auth-hook-http.ts
Original file line number Diff line number Diff line change
@@ -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<Response>) {
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<string>();
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<Uint8Array>, 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;
}
1 change: 1 addition & 0 deletions apps/api/src/lib/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/lib/ses-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/plugins/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading