From c6dc950709f4b7dc46655542e0583f3017b31d20 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Tue, 11 Aug 2026 17:18:44 -0700 Subject: [PATCH 1/4] feat: harden production operations --- .env.example | 5 + .gitignore | 1 + apps/web/next.config.ts | 17 ++++ apps/web/package.json | 1 + apps/web/src/app/api/cron/retention/route.ts | 16 ++++ apps/web/src/app/api/health/route.test.ts | 20 +++- apps/web/src/app/api/health/route.ts | 35 ++++++- apps/web/src/lib/ingress-handler.test.ts | 36 ++++++++ apps/web/src/lib/ingress-handler.ts | 47 ++++++++-- apps/web/src/lib/retention-handler.test.ts | 44 +++++++++ apps/web/src/lib/retention-handler.ts | 60 ++++++++++++ apps/web/src/lib/server-database.ts | 5 + apps/web/vercel.json | 9 ++ bun.lock | 39 ++++++++ e2e/home.e2e.ts | 7 +- packages/database/src/index.ts | 1 + packages/database/src/retention-store.test.ts | 92 +++++++++++++++++++ packages/database/src/retention-store.ts | 36 ++++++++ 18 files changed, 457 insertions(+), 14 deletions(-) create mode 100644 .env.example create mode 100644 apps/web/src/app/api/cron/retention/route.ts create mode 100644 apps/web/src/lib/retention-handler.test.ts create mode 100644 apps/web/src/lib/retention-handler.ts create mode 100644 apps/web/vercel.json create mode 100644 packages/database/src/retention-store.test.ts create mode 100644 packages/database/src/retention-store.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..708ed58 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL= +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 +CRON_SECRET= +RETENTION_DAYS=30 diff --git a/.gitignore b/.gitignore index 1212376..a6dd0cc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .turbo/ dist/ out/ +.vercel/ # Test output playwright-report/ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 9a2c1ce..c9e06e6 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,6 +3,23 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ["@hooky/database"], + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 4cadc2b..0041017 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hooky/database": "workspace:*", + "@vercel/functions": "^3.9.3", "better-auth": "1.6.27", "drizzle-orm": "0.45.2", "next": "16.3.0", diff --git a/apps/web/src/app/api/cron/retention/route.ts b/apps/web/src/app/api/cron/retention/route.ts new file mode 100644 index 0000000..b9dec63 --- /dev/null +++ b/apps/web/src/app/api/cron/retention/route.ts @@ -0,0 +1,16 @@ +import { createRetentionHandler } from "@/lib/retention-handler"; +import { retentionStore } from "@/lib/server-database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const configuredRetentionDays = Number(process.env.RETENTION_DAYS ?? "30"); + +export const GET = createRetentionHandler({ + cronSecret: process.env.CRON_SECRET, + retentionDays: Number.isFinite(configuredRetentionDays) + ? configuredRetentionDays + : 30, + deleteEventsReceivedBefore: (input) => + retentionStore.deleteEventsReceivedBefore(input), +}); diff --git a/apps/web/src/app/api/health/route.test.ts b/apps/web/src/app/api/health/route.test.ts index 0ad9e64..9edde97 100644 --- a/apps/web/src/app/api/health/route.test.ts +++ b/apps/web/src/app/api/health/route.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { GET } from "./route"; +import { createHealthHandler } from "./route"; describe("health endpoint", () => { test("reports that the web service is ready", async () => { - const response = GET(); + const response = await createHealthHandler({ + checkDatabase: async () => undefined, + })(); expect(response.status).toBe(200); expect(await response.json()).toEqual({ @@ -12,4 +14,18 @@ describe("health endpoint", () => { status: "ok", }); }); + + test("reports an unavailable dependency without leaking its error", async () => { + const response = await createHealthHandler({ + checkDatabase: async () => { + throw new Error("postgresql://secret@database.internal/hooky"); + }, + })(); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + service: "hooky-web", + status: "unavailable", + }); + }); }); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index 5a56843..4ad7ce0 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,6 +1,31 @@ -export function GET() { - return Response.json({ - service: "hooky-web", - status: "ok", - }); +import { databasePool } from "@/lib/server-database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function createHealthHandler({ + checkDatabase, +}: { + checkDatabase: () => Promise; +}) { + return async function healthHandler() { + try { + await checkDatabase(); + return Response.json( + { service: "hooky-web", status: "ok" }, + { headers: { "cache-control": "no-store" } }, + ); + } catch { + return Response.json( + { service: "hooky-web", status: "unavailable" }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + }; } + +export const GET = createHealthHandler({ + checkDatabase: async () => { + await databasePool.query("select 1"); + }, +}); diff --git a/apps/web/src/lib/ingress-handler.test.ts b/apps/web/src/lib/ingress-handler.test.ts index 2a98cc7..d41e9b4 100644 --- a/apps/web/src/lib/ingress-handler.test.ts +++ b/apps/web/src/lib/ingress-handler.test.ts @@ -41,6 +41,7 @@ describe("ingress handler", () => { ); expect(response.status).toBe(202); + expect(response.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/); expect(await response.json()).toEqual({ deliveryId: "delivery-one", eventId: "event-one", @@ -52,6 +53,41 @@ describe("ingress handler", () => { ]); }); + test("preserves an upstream Vercel request id for correlation", async () => { + const logs: Record[] = []; + const handler = createIngressHandler({ + maxBodyBytes: 1024, + resolveIngressToken: async () => ({ + accountId: "account-one", + hookId: "hook-one", + }), + recordWebhookEvent: async () => ({ + eventId: "event-one", + deliveryId: "delivery-one", + }), + log: (entry) => logs.push(entry), + }); + + const response = await handler( + new Request("https://hooky.test/e/token", { + method: "POST", + headers: { "x-vercel-id": "sfo1::abc-123" }, + }), + { token: "token", path: [] }, + ); + + expect(response.headers.get("x-request-id")).toBe("sfo1::abc-123"); + expect(logs).toEqual([ + expect.objectContaining({ + level: "info", + message: "webhook.accepted", + requestId: "sfo1::abc-123", + method: "POST", + bodyBytes: 0, + }), + ]); + }); + test("rejects unknown tokens without recording", async () => { let recorded = false; const handler = createIngressHandler({ diff --git a/apps/web/src/lib/ingress-handler.ts b/apps/web/src/lib/ingress-handler.ts index fcaabac..ae34641 100644 --- a/apps/web/src/lib/ingress-handler.ts +++ b/apps/web/src/lib/ingress-handler.ts @@ -27,6 +27,7 @@ export function createIngressHandler({ maxBodyBytes, resolveIngressToken, recordWebhookEvent, + log = (entry) => console.log(JSON.stringify(entry)), }: { maxBodyBytes: number; resolveIngressToken: (token: string) => Promise; @@ -40,27 +41,40 @@ export function createIngressHandler({ body: Buffer; receivedAt: Date; }) => Promise; + log?: (entry: Record) => void; }) { return async function handleIngress( request: Request, { token, path }: { token: string; path: string[] }, ) { + const startedAt = Date.now(); + const requestId = request.headers.get("x-vercel-id") ?? crypto.randomUUID(); + const responseHeaders = { + "cache-control": "no-store", + "x-request-id": requestId, + }; const contentLength = Number(request.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { - return Response.json({ error: "Payload too large" }, { status: 413 }); + return Response.json( + { error: "Payload too large" }, + { status: 413, headers: responseHeaders }, + ); } const resolved = await resolveIngressToken(token); if (!resolved) { return Response.json( { error: "Webhook endpoint not found" }, - { status: 404 }, + { status: 404, headers: responseHeaders }, ); } const body = Buffer.from(await request.arrayBuffer()); if (body.byteLength > maxBodyBytes) { - return Response.json({ error: "Payload too large" }, { status: 413 }); + return Response.json( + { error: "Payload too large" }, + { status: 413, headers: responseHeaders }, + ); } try { @@ -74,14 +88,35 @@ export function createIngressHandler({ receivedAt: new Date(), }); + log({ + level: "info", + message: "webhook.accepted", + requestId, + eventId: recorded.eventId, + hookId: resolved.hookId, + method: request.method, + bodyBytes: body.byteLength, + durationMs: Date.now() - startedAt, + }); + return Response.json(recorded, { status: 202, - headers: { "cache-control": "no-store" }, + headers: responseHeaders, + }); + } catch (error) { + log({ + level: "error", + message: "webhook.failed", + requestId, + hookId: resolved.hookId, + method: request.method, + bodyBytes: body.byteLength, + errorType: error instanceof Error ? error.name : "UnknownError", + durationMs: Date.now() - startedAt, }); - } catch { return Response.json( { error: "Webhook could not be durably accepted" }, - { status: 503 }, + { status: 503, headers: responseHeaders }, ); } }; diff --git a/apps/web/src/lib/retention-handler.test.ts b/apps/web/src/lib/retention-handler.test.ts new file mode 100644 index 0000000..a58e285 --- /dev/null +++ b/apps/web/src/lib/retention-handler.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { createRetentionHandler } from "./retention-handler"; + +describe("retention handler", () => { + test("requires the configured cron bearer secret", async () => { + const handler = createRetentionHandler({ + cronSecret: "cron-secret", + retentionDays: 30, + deleteEventsReceivedBefore: async () => ({ deleted: 0 }), + now: () => new Date("2026-08-11T00:00:00.000Z"), + }); + + const response = await handler( + new Request("https://hooky.test/api/cron/retention"), + ); + + expect(response.status).toBe(401); + }); + + test("deletes events older than the configured retention window", async () => { + let receivedBefore: Date | undefined; + const handler = createRetentionHandler({ + cronSecret: "cron-secret", + retentionDays: 30, + deleteEventsReceivedBefore: async ({ before, limit }) => { + receivedBefore = before; + expect(limit).toBe(10_000); + return { deleted: 42 }; + }, + now: () => new Date("2026-08-11T00:00:00.000Z"), + }); + + const response = await handler( + new Request("https://hooky.test/api/cron/retention", { + headers: { authorization: "Bearer cron-secret" }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ deleted: 42, retentionDays: 30 }); + expect(receivedBefore).toEqual(new Date("2026-07-12T00:00:00.000Z")); + }); +}); diff --git a/apps/web/src/lib/retention-handler.ts b/apps/web/src/lib/retention-handler.ts new file mode 100644 index 0000000..150118c --- /dev/null +++ b/apps/web/src/lib/retention-handler.ts @@ -0,0 +1,60 @@ +const millisecondsPerDay = 24 * 60 * 60 * 1000; + +function clampRetentionDays(value: number) { + return Math.min(365, Math.max(1, Math.trunc(value))); +} + +export function createRetentionHandler({ + cronSecret, + retentionDays, + deleteEventsReceivedBefore, + now = () => new Date(), + log = (entry) => console.log(JSON.stringify(entry)), +}: { + cronSecret: string | undefined; + retentionDays: number; + deleteEventsReceivedBefore: (input: { + before: Date; + limit: number; + }) => Promise<{ deleted: number }>; + now?: () => Date; + log?: (entry: Record) => void; +}) { + return async function handleRetention(request: Request) { + const startedAt = Date.now(); + const requestId = request.headers.get("x-vercel-id") ?? crypto.randomUUID(); + + if ( + !cronSecret || + request.headers.get("authorization") !== `Bearer ${cronSecret}` + ) { + return Response.json( + { error: "Unauthorized" }, + { status: 401, headers: { "x-request-id": requestId } }, + ); + } + + const safeRetentionDays = clampRetentionDays(retentionDays); + const before = new Date( + now().getTime() - safeRetentionDays * millisecondsPerDay, + ); + const result = await deleteEventsReceivedBefore({ + before, + limit: 10_000, + }); + + log({ + level: "info", + message: "retention.completed", + requestId, + deleted: result.deleted, + retentionDays: safeRetentionDays, + durationMs: Date.now() - startedAt, + }); + + return Response.json( + { ...result, retentionDays: safeRetentionDays }, + { headers: { "cache-control": "no-store", "x-request-id": requestId } }, + ); + }; +} diff --git a/apps/web/src/lib/server-database.ts b/apps/web/src/lib/server-database.ts index e762d53..d6ff476 100644 --- a/apps/web/src/lib/server-database.ts +++ b/apps/web/src/lib/server-database.ts @@ -6,7 +6,9 @@ import { DeliveryStore, EventStore, HookStore, + RetentionStore, } from "@hooky/database"; +import { attachDatabasePool } from "@vercel/functions"; const connectionString = process.env.DATABASE_URL ?? @@ -20,6 +22,8 @@ export const databasePool = globalDatabase.hookyPool ?? createDatabasePool({ connectionString, maxConnections: 3 }); +attachDatabasePool(databasePool); + if (process.env.NODE_ENV !== "production") { globalDatabase.hookyPool = databasePool; } @@ -30,3 +34,4 @@ export const apiTokenStore = new ApiTokenStore(databasePool); export const hookStore = new HookStore(databasePool); export const deliveryStore = new DeliveryStore(databasePool); export const eventStore = new EventStore(databasePool); +export const retentionStore = new RetentionStore(databasePool); diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..6c39870 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/retention", + "schedule": "0 4 * * *" + } + ] +} diff --git a/bun.lock b/bun.lock index 1354b24..dec44d8 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "version": "0.0.0", "dependencies": { "@hooky/database": "workspace:*", + "@vercel/functions": "^3.9.3", "better-auth": "1.6.27", "drizzle-orm": "0.45.2", "next": "16.3.0", @@ -419,6 +420,14 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="], + + "@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="], + + "@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -589,6 +598,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -629,6 +640,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], @@ -659,6 +672,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -709,6 +724,8 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], @@ -765,10 +782,14 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -789,6 +810,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -805,8 +828,12 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -921,6 +948,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -947,6 +976,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1005,6 +1036,10 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1037,6 +1072,10 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/oidc/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], diff --git a/e2e/home.e2e.ts b/e2e/home.e2e.ts index acfcfe1..f5cf87b 100644 --- a/e2e/home.e2e.ts +++ b/e2e/home.e2e.ts @@ -1,7 +1,12 @@ import { expect, test } from "@playwright/test"; test("introduces Hooky and its core promise", async ({ page }) => { - await page.goto("/"); + const response = await page.goto("/"); + + expect(response?.headers()["x-content-type-options"]).toBe("nosniff"); + expect(response?.headers()["referrer-policy"]).toBe( + "strict-origin-when-cross-origin", + ); await expect( page.getByRole("heading", { name: "Webhooks should wait for you." }), diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index af5b9e9..1e50885 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -8,4 +8,5 @@ export { } from "./delivery-store"; export { EventStore } from "./event-store"; export { HookStore } from "./hook-store"; +export { RetentionStore } from "./retention-store"; export * as schema from "./schema"; diff --git a/packages/database/src/retention-store.test.ts b/packages/database/src/retention-store.test.ts new file mode 100644 index 0000000..915576e --- /dev/null +++ b/packages/database/src/retention-store.test.ts @@ -0,0 +1,92 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore } from "./delivery-store"; +import { RetentionStore } from "./retention-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let deliveryStore: DeliveryStore; +let retentionStore: RetentionStore; + +beforeAll(async () => { + database = await createTestDatabase(); + deliveryStore = new DeliveryStore(database.pool); + retentionStore = new RetentionStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("retention store", () => { + test("deletes old events and their delivery history in bounded batches", async () => { + const hook = await database.seedAccountAndHook(); + const oldEvent = await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/old", + query: {}, + headers: {}, + body: Buffer.from("old"), + receivedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + const currentEvent = await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/current", + query: {}, + headers: {}, + body: Buffer.from("current"), + receivedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + expect( + await retentionStore.deleteEventsReceivedBefore({ + before: new Date("2026-07-01T00:00:00.000Z"), + limit: 1, + }), + ).toEqual({ deleted: 1 }); + + const remaining = await database.pool.query<{ id: string }>( + "select id from webhook_events order by received_at", + ); + expect(remaining.rows).toEqual([{ id: currentEvent.eventId }]); + expect( + await database.pool.query( + "select id from deliveries where event_id = $1", + [oldEvent.eventId], + ), + ).toMatchObject({ rowCount: 0 }); + }); + + test("clamps cleanup work to a safe maximum", async () => { + const hook = await database.seedAccountAndHook(); + await deliveryStore.recordWebhookEvent({ + ...hook, + requestMethod: "POST", + requestPath: "/old", + query: {}, + headers: {}, + body: Buffer.from("old"), + receivedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + expect( + await retentionStore.deleteEventsReceivedBefore({ + before: new Date("2026-07-01T00:00:00.000Z"), + limit: 0, + }), + ).toEqual({ deleted: 1 }); + }); +}); diff --git a/packages/database/src/retention-store.ts b/packages/database/src/retention-store.ts new file mode 100644 index 0000000..68199c0 --- /dev/null +++ b/packages/database/src/retention-store.ts @@ -0,0 +1,36 @@ +import type { Pool } from "pg"; + +function clampLimit(value: number) { + return Math.min(10_000, Math.max(1, Math.trunc(value))); +} + +export class RetentionStore { + constructor(private readonly pool: Pool) {} + + async deleteEventsReceivedBefore({ + before, + limit, + }: { + before: Date; + limit: number; + }) { + const result = await this.pool.query<{ id: string }>( + ` + with candidates as materialized ( + select id + from webhook_events + where received_at < $1 + order by received_at, id + limit $2 + ) + delete from webhook_events + using candidates + where webhook_events.id = candidates.id + returning webhook_events.id + `, + [before, clampLimit(limit)], + ); + + return { deleted: result.rowCount ?? 0 }; + } +} From 5b2673df4b97531797ffab39c5070cb2adcad584 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Tue, 11 Aug 2026 17:25:29 -0700 Subject: [PATCH 2/4] docs: add production quickstart --- README.md | 104 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index eefe33c..a0c4aa9 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,120 @@ # Hooky -Hooky is a durable webhook inbox and local development relay. It stores incoming webhooks in the cloud and delivers them when a developer's local environment is ready—without exposing localhost to the public internet. +Hooky is a durable webhook inbox and local-development relay. A public Hooky URL accepts and stores a webhook before returning `202`, then the Hooky CLI leases that event and forwards its original method, path, query, headers, and bytes to localhost. Your development machine never exposes a public port. -The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1). +The hosted application is [hooky.vercel.app](https://hooky.vercel.app). The product plan and implementation decisions live in [PRD issue #1](https://github.com/dak-engineering/hooky/issues/1). -## Repository +## Use Hooky -This Bun workspace currently contains: +Hooky is not published to npm. Run it directly from this public GitHub repository: -- `apps/web`: the Next.js web application and API surface. -- `packages/database`: the Neon/PostgreSQL schema, migrations, and durable delivery state machine. +```bash +bunx github:dak-engineering/hooky --help +``` -Relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them. +Or install the command globally from GitHub: -## Development +```bash +bun install --global github:dak-engineering/hooky +hooky --help +``` -Install dependencies and start the web application: +Create an account in the web app, open **API keys**, and copy a newly created key. Authenticate the CLI: ```bash -bun install -bun run dev +hooky login --token hky_your_token ``` -The application runs at [http://localhost:3000](http://localhost:3000), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health). +Create a new webhook URL and relay its events to a local route: -## Database +```bash +hooky listen --new stripe-dev --to http://localhost:3000/webhooks/stripe +``` -Application traffic must use Neon's pooled connection string through `DATABASE_URL`. The database package keeps captured webhook events immutable and uses short PostgreSQL statements with `FOR UPDATE SKIP LOCKED` for concurrent delivery claims. +The command prints the one-time public webhook URL. Give that URL to Stripe, GitHub, Clerk, or any service that sends HTTP webhooks. -Generate a migration after changing the Drizzle schema: +To listen on a hook that already exists in your account: ```bash -bun run db:generate +hooky hooks +hooky listen --hook stripe-dev --to http://localhost:3000/webhooks/stripe +``` + +The CLI stores credentials in `~/.config/hooky/config.json` with user-only permissions. You can instead use `HOOKY_TOKEN`, `HOOKY_API_URL`, and `HOOKY_CONFIG_PATH`. + +## Delivery model + +```mermaid +flowchart LR + sender["Webhook sender"] -->|"public HTTPS request"| ingress["Hooky on Vercel"] + ingress -->|"atomic event + delivery commit"| neon["Neon Postgres"] + cli["Hooky CLI"] -->|"authenticated claim / ACK / NACK"| ingress + cli -->|"original HTTP request"| local["localhost application"] ``` -Apply committed migrations to the database in `DATABASE_URL`: +- Hook ingress secrets and API keys are random, one-time credentials stored only as SHA-256 hashes. +- Each accepted request and pending delivery are committed together before Hooky responds. +- A listener claims work using a time-bounded database lease. Unacknowledged work becomes claimable again after the lease expires. +- Successful local responses ACK the delivery. Network errors and unsuccessful responses NACK it with bounded exponential retry. +- Every account boundary is enforced in the database queries used by management and listener APIs. +- Captured events are retained for 30 days by default. A daily authenticated Vercel Cron job performs bounded cleanup. + +## Repository + +This Bun/Turborepo workspace contains: + +- `apps/web`: Next.js application, authenticated dashboard, ingress, management API, listener API, health check, and retention cron. +- `packages/database`: Drizzle schema, migrations, tenant-scoped stores, and PostgreSQL delivery state machine. +- `packages/cli`: GitHub-installable Bun CLI and local forwarding loop. +- `e2e`: Playwright coverage for the landing page and the sign-up → hook → webhook → event → API-key flow. + +## Local development + +Requirements are Bun 1.3+, PostgreSQL 16+, and Chromium for browser tests. ```bash -bun run db:migrate +bun install +cp .env.example apps/web/.env.local ``` -Database integration tests use `TEST_DATABASE_URL` when supplied. Otherwise, they start and remove an ephemeral local PostgreSQL cluster with `initdb` and `pg_ctl`. +Set these values: + +```dotenv +DATABASE_URL=postgresql://... +BETTER_AUTH_SECRET=a-random-secret-at-least-32-characters-long +BETTER_AUTH_URL=http://localhost:3000 +CRON_SECRET=another-random-secret +RETENTION_DAYS=30 +``` + +Then migrate and run the application: + +```bash +DATABASE_URL='postgresql://...' bun run db:migrate +bun run dev +``` + +The application runs at [http://localhost:3000](http://localhost:3000). Readiness is available at [http://localhost:3000/api/health](http://localhost:3000/api/health). + +Application traffic should use Neon's pooled `DATABASE_URL`. Run migrations with a direct connection string when available because migration tools hold longer sessions than request handlers. ## Validation +Database tests use `TEST_DATABASE_URL` when supplied. Otherwise they create and remove an ephemeral local PostgreSQL cluster. + ```bash +bun run prettier bun run lint bun run prettier:check bun run typecheck bun test bun run test:e2e bun run build +bun run db:generate ``` + +## Deployment + +The production project is `dak/hooky` on Vercel, connected to this repository with `apps/web` as its Root Directory. Neon supplies pooled `DATABASE_URL` credentials to the Vercel project. Production also requires `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and `CRON_SECRET`; `RETENTION_DAYS` defaults to 30. + +The service emits metadata-only structured ingress logs with Vercel request correlation. It never logs webhook bodies, captured headers, ingress secrets, API keys, or database credentials. From f8a2743e767a7c9e32e8e2070e622d6715b4424d Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Tue, 11 Aug 2026 17:26:29 -0700 Subject: [PATCH 3/4] fix: expose deployment env to Turbo builds --- turbo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/turbo.json b/turbo.json index 8207f92..df15fca 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,13 @@ "tasks": { "build": { "dependsOn": ["^build"], + "env": [ + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "CRON_SECRET", + "DATABASE_URL", + "RETENTION_DAYS" + ], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "dev": { From 81bcb345704ba5d7f5b6df159d0ca828b2523d9b Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Tue, 11 Aug 2026 17:28:06 -0700 Subject: [PATCH 4/4] fix: resolve auth origin on preview deployments --- apps/web/src/lib/auth.ts | 7 ++----- apps/web/src/lib/deployment-origin.test.ts | 24 ++++++++++++++++++++++ apps/web/src/lib/deployment-origin.ts | 15 ++++++++++++++ turbo.json | 3 ++- 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/deployment-origin.test.ts create mode 100644 apps/web/src/lib/deployment-origin.ts diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 7d22b13..72e9796 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { database } from "./server-database"; +import { resolveDeploymentOrigin } from "./deployment-origin"; const fallbackDevelopmentSecret = "hooky-development-only-secret-change-before-deploying"; @@ -12,11 +13,7 @@ if (process.env.VERCEL && !process.env.BETTER_AUTH_SECRET) { throw new Error("BETTER_AUTH_SECRET is required on Vercel"); } -if (process.env.VERCEL && !process.env.BETTER_AUTH_URL) { - throw new Error("BETTER_AUTH_URL is required on Vercel"); -} - -const configuredOrigin = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; +const configuredOrigin = resolveDeploymentOrigin(process.env); export function createHookyAuth({ database, diff --git a/apps/web/src/lib/deployment-origin.test.ts b/apps/web/src/lib/deployment-origin.test.ts new file mode 100644 index 0000000..33fce1c --- /dev/null +++ b/apps/web/src/lib/deployment-origin.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveDeploymentOrigin } from "./deployment-origin"; + +describe("deployment origin", () => { + test("prefers the explicitly configured canonical URL", () => { + expect( + resolveDeploymentOrigin({ + BETTER_AUTH_URL: "https://hooky.vercel.app", + VERCEL_URL: "hooky-preview.vercel.app", + }), + ).toBe("https://hooky.vercel.app"); + }); + + test("uses the current Vercel deployment URL for previews", () => { + expect( + resolveDeploymentOrigin({ VERCEL_URL: "hooky-preview.vercel.app" }), + ).toBe("https://hooky-preview.vercel.app"); + }); + + test("falls back to localhost for local development", () => { + expect(resolveDeploymentOrigin({})).toBe("http://localhost:3000"); + }); +}); diff --git a/apps/web/src/lib/deployment-origin.ts b/apps/web/src/lib/deployment-origin.ts new file mode 100644 index 0000000..b7313ab --- /dev/null +++ b/apps/web/src/lib/deployment-origin.ts @@ -0,0 +1,15 @@ +export function resolveDeploymentOrigin( + environment: Record, +) { + const configuredOrigin = environment.BETTER_AUTH_URL?.trim(); + if (configuredOrigin) { + return configuredOrigin; + } + + const vercelUrl = environment.VERCEL_URL?.trim(); + if (vercelUrl) { + return `https://${vercelUrl}`; + } + + return "http://localhost:3000"; +} diff --git a/turbo.json b/turbo.json index df15fca..0cf138d 100644 --- a/turbo.json +++ b/turbo.json @@ -8,7 +8,8 @@ "BETTER_AUTH_URL", "CRON_SECRET", "DATABASE_URL", - "RETENTION_DAYS" + "RETENTION_DAYS", + "VERCEL_URL" ], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },