diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 11658090..d54461c8 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -89,6 +89,7 @@ cp .env.example .env.local | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | accounts | The anon/publishable key. Safe in the browser — row-level security is what protects the data. | | `CRON_SECRET` | the retention sweep | At least 16 characters. Unset means the sweep endpoint refuses everything, including your scheduler. | | `NEXT_PUBLIC_SENTRY_DSN` | error reporting | Optional. Unset means no Sentry, browser or server. | +| `LOG_LEVEL` | server logs | Optional. `fatal`, `error`, `warn`, `info`, `debug`, `trace` or `silent`. Unset means `info` in production and `debug` elsewhere. | Two things that will catch you out: @@ -175,6 +176,25 @@ Then, in the browser: If step 2 says accounts are not set up, go back to section 3 — it is almost always `NEXT_PUBLIC_SUPABASE_URL` or `NEXT_PUBLIC_SUPABASE_ANON_KEY` missing. +### Health, versions and logs + +`GET /api/health` answers 200 when everything the instance is configured to +use responds, and 503 when the database does not. Point an uptime monitor at +it. It also names the build: + +```sh +curl -s https://your-host/api/health +# {"status":"ok","version":"0.1.0","commit":"ce429f1","builtAt":"…","environment":"production", +# "checks":{"database":"ok","accounts":"configured","errorReporting":"configured"}} +``` + +Every response carries `X-App-Version`, `X-Commit-SHA` and `X-Request-ID`. In +a browser console, `window.appVersion` says the same. The server logs one JSON +object per line, and every line written while handling a request carries +that request's `requestId`. To find what the server did for a request, search +the logs for the `X-Request-ID` it returned. A caller's own `X-Request-ID` or +`X-Correlation-ID` is kept when it is a plain token of up to 128 characters. + ## 7. Deploy it somewhere The hosted instance runs on Vercel with **Root Directory** set to `suite`, and diff --git a/package-lock.json b/package-lock.json index 9534caba..aa160687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2339,6 +2339,12 @@ "pako": "^1.0.10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5193,6 +5199,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -6444,6 +6459,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6465,6 +6481,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6486,6 +6503,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6507,6 +6525,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6528,6 +6547,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6549,6 +6569,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6570,6 +6591,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6591,6 +6613,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6612,6 +6635,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6633,6 +6657,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6654,6 +6679,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7036,6 +7062,15 @@ "node": ">=18" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -7189,6 +7224,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", @@ -7225,6 +7297,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7280,6 +7353,22 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -7305,6 +7394,12 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -7344,6 +7439,15 @@ "license": "MIT", "peer": true }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7483,6 +7587,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7643,6 +7756,15 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7682,6 +7804,15 @@ "specificity": "bin/specificity" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8015,6 +8146,24 @@ "utrie": "^1.0.2" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -8793,6 +8942,7 @@ "lucide-react": "^1.47.0", "next": "16.3.5", "pdf-lib": "^1.17.1", + "pino": "^10.3.1", "react": "19.3.0", "react-dom": "19.3.0", "svg2pdf.js": "^2.8.1", diff --git a/suite/.env.example b/suite/.env.example index c16ecd5a..1e3f9ab7 100644 --- a/suite/.env.example +++ b/suite/.env.example @@ -40,3 +40,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # unset means the sweep endpoint refuses everything, including Vercel. An # endpoint whose job is deleting fails closed. # CRON_SECRET= + +# Server log verbosity: fatal, error, warn, info, debug, trace or silent. +# Unset means info in production and debug everywhere else. +# LOG_LEVEL= diff --git a/suite/app/api/accounts/delete/route.test.ts b/suite/app/api/accounts/delete/route.test.ts index 6b21dd9e..ed260a87 100644 --- a/suite/app/api/accounts/delete/route.test.ts +++ b/suite/app/api/accounts/delete/route.test.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/accounts/serverClient", () => ({ vi.mock("@/lib/accounts/supabaseStore", () => ({ accountsStore: () => store })); const { POST } = await import("./route"); +const request = () => new Request("http://localhost/api/accounts/delete", { method: "POST" }); beforeEach(() => { currentUserResult = { id: `user-${Math.random()}`, email: "a@example.com" }; @@ -30,9 +31,9 @@ test("account deletion past the limit is throttled, per account", async () => { // then stops at the missing service-role key (mocked env has neither), so // calls 1-5 land on that same 500, distinctly from the 429 call 6 must get. for (let i = 0; i < 5; i += 1) { - const response = await POST(); + const response = await POST(request()); expect(response.status).toBe(500); } - const sixth = await POST(); + const sixth = await POST(request()); expect(sixth.status).toBe(429); }); diff --git a/suite/app/api/accounts/delete/route.ts b/suite/app/api/accounts/delete/route.ts index c593d89b..6d4f921e 100644 --- a/suite/app/api/accounts/delete/route.ts +++ b/suite/app/api/accounts/delete/route.ts @@ -5,16 +5,17 @@ import { deleteAccountHandler } from "@/lib/accounts/handlers"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function POST() { +export async function POST(request: Request) { try { return await deleteAccount(); } catch (error) { // See the note in `../wedding/route.ts`. - console.error("[accounts] POST /api/accounts/delete", error); + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/delete"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/accounts/invite/[token]/route.ts b/suite/app/api/accounts/invite/[token]/route.ts index f6304617..863b3757 100644 --- a/suite/app/api/accounts/invite/[token]/route.ts +++ b/suite/app/api/accounts/invite/[token]/route.ts @@ -5,11 +5,12 @@ import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, tokenSchema } from "@/lib/accounts/schemas"; import { allow, AUTH_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function POST(_request: Request, context: { params: Promise<{ token: string }> }) { +export async function POST(request: Request, context: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) { return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); @@ -32,7 +33,7 @@ export async function POST(_request: Request, context: { params: Promise<{ token return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { // See the note in `../../wedding/route.ts`. - console.error("[accounts] POST /api/accounts/invite/[token]", error); + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/invite/[token]"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/accounts/invite/route.ts b/suite/app/api/accounts/invite/route.ts index 47f2abf2..27239c73 100644 --- a/suite/app/api/accounts/invite/route.ts +++ b/suite/app/api/accounts/invite/route.ts @@ -5,6 +5,7 @@ import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, inviteSchema } from "@/lib/accounts/schemas"; import { allow, INVITE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,8 +17,8 @@ const throttled = () => NextResponse.json({ error: "Too many requests. Wait a while and try again." }, { status: 429 }); /** See the note in `../wedding/route.ts`: an uncaught throw here becomes an HTML 500 the UI can't parse. */ -const failed = (error: unknown) => { - console.error("[accounts] POST /api/accounts/invite", error); +const failed = (request: Request, error: unknown) => { + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/invite"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -66,6 +67,6 @@ export async function POST(request: Request) { return NextResponse.json(reply.body, { status: 200 }); } catch (error) { - return failed(error); + return failed(request, error); } } diff --git a/suite/app/api/accounts/members/route.ts b/suite/app/api/accounts/members/route.ts index e2cc0a17..6b743c77 100644 --- a/suite/app/api/accounts/members/route.ts +++ b/suite/app/api/accounts/members/route.ts @@ -4,6 +4,7 @@ import { peopleHandler, removeMemberHandler } from "@/lib/accounts/handlers"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, removeMemberSchema } from "@/lib/accounts/schemas"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -12,8 +13,8 @@ const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); /** See `../weddings/route.ts`: an uncaught throw becomes an HTML 500 the UI can't parse. */ -const failed = (where: string, error: unknown) => { - console.error(`[accounts] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[accounts] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -30,7 +31,7 @@ export async function GET(request: Request) { const reply = await peopleHandler(accountsStore(client), weddingId, user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/accounts/members", error); + return failed(request, "GET /api/accounts/members", error); } } @@ -50,6 +51,6 @@ export async function DELETE(request: Request) { const reply = await removeMemberHandler(accountsStore(client), input.value.weddingId, user.id, input.value.userId); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("DELETE /api/accounts/members", error); + return failed(request, "DELETE /api/accounts/members", error); } } diff --git a/suite/app/api/accounts/weddings/route.ts b/suite/app/api/accounts/weddings/route.ts index 786af78b..bda9822c 100644 --- a/suite/app/api/accounts/weddings/route.ts +++ b/suite/app/api/accounts/weddings/route.ts @@ -6,6 +6,7 @@ import { documentStore } from "@/lib/documents/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, newWeddingSchema } from "@/lib/accounts/schemas"; import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,8 +23,8 @@ const throttled = () => * into Next's default 500, whose body is HTML: the browser's `response.json()` * then rejects and the calling page hangs on its loading state forever. */ -const failed = (where: string, error: unknown) => { - console.error(`[accounts] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[accounts] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -44,7 +45,7 @@ export async function GET(request: Request) { const reply = await listWeddingsHandler(accountsStore(client), documentStore(client), user.id, today); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/accounts/weddings", error); + return failed(request, "GET /api/accounts/weddings", error); } } @@ -66,6 +67,6 @@ export async function POST(request: Request) { const reply = await createWeddingHandler(accountsStore(client), user.id, input.value.role); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("POST /api/accounts/weddings", error); + return failed(request, "POST /api/accounts/weddings", error); } } diff --git a/suite/app/api/cron/sweep/route.ts b/suite/app/api/cron/sweep/route.ts index 680c2833..531a21b0 100644 --- a/suite/app/api/cron/sweep/route.ts +++ b/suite/app/api/cron/sweep/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { env } from "@/lib/env"; import { sweepAbandonedDocuments } from "@/lib/documents/handlers"; import { adminDocumentsClient, documentStore } from "@/lib/documents/supabaseStore"; +import { requestLog } from "@/lib/server/log"; /** * Retention, once a day: account weddings nobody has written to inside the @@ -33,12 +34,12 @@ export async function GET(request: Request) { try { const { deleted } = await sweepAbandonedDocuments(documentStore(adminClient)); // A count only: ids identify rows, and a log is no place for them. - console.info(`[Trousseau] retention sweep removed ${deleted.length} wedding(s)`); + requestLog(request).info({ deleted: deleted.length }, "[Trousseau] retention sweep"); return NextResponse.json({ deleted: deleted.length }); } catch (cause) { // A failed sweep must be loud: it deletes, it runs unattended, and silence // here means data kept past the period the Privacy Policy states. - console.error("[Trousseau] retention sweep failed:", cause); + requestLog(request).error({ err: cause }, "[Trousseau] retention sweep failed"); return NextResponse.json({ error: "The sweep failed." }, { status: 503 }); } } diff --git a/suite/app/api/documents/export/route.ts b/suite/app/api/documents/export/route.ts index 2d9a431d..884e724c 100644 --- a/suite/app/api/documents/export/route.ts +++ b/suite/app/api/documents/export/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { exportDocumentHandler } from "@/lib/documents/handlers"; import { allow, EXPORT_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; /** * "Download my wedding" — the honest answer to "can I get my data out". @@ -60,7 +61,7 @@ export async function GET(request: Request) { }, }); } catch (error) { - console.error("[documents] GET /api/documents/export", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/export"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/history/[id]/route.ts b/suite/app/api/documents/history/[id]/route.ts index 0ec17efa..a88fd28c 100644 --- a/suite/app/api/documents/history/[id]/route.ts +++ b/suite/app/api/documents/history/[id]/route.ts @@ -6,6 +6,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { historyDocumentHandler } from "@/lib/documents/handlers"; import { check } from "@/lib/server/check"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const reply = await historyDocumentHandler(documentStore(client), weddingId, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - console.error("[documents] GET /api/documents/history/[id]", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/history/[id]"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/history/route.ts b/suite/app/api/documents/history/route.ts index 7c206f7c..f8f59645 100644 --- a/suite/app/api/documents/history/route.ts +++ b/suite/app/api/documents/history/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { documentStore } from "@/lib/documents/supabaseStore"; import { historyHandler } from "@/lib/documents/handlers"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -24,7 +25,7 @@ export async function GET(request: Request) { const reply = await historyHandler(documentStore(client), people, weddingId, user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - console.error("[documents] GET /api/documents/history", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/history"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/route.ts b/suite/app/api/documents/route.ts index 6ca34340..d90445a4 100644 --- a/suite/app/api/documents/route.ts +++ b/suite/app/api/documents/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { getDocumentHandler, saveDocumentHandler } from "@/lib/documents/handlers"; import { allow, WRITE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -20,8 +21,8 @@ const noWedding = () => const throttled = () => NextResponse.json({ error: "Too many requests. Wait a minute and try again." }, { status: 429 }); -const failed = (where: string, error: unknown) => { - console.error(`[documents] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[documents] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -40,7 +41,7 @@ export async function GET(request: Request) { const reply = await getDocumentHandler(documentStore(client), weddingId); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/documents", error); + return failed(request, "GET /api/documents", error); } } @@ -82,6 +83,6 @@ export async function PUT(request: Request) { const reply = await saveDocumentHandler(documentStore(client), weddingId, body.document, body.expectedVersion); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("PUT /api/documents", error); + return failed(request, "PUT /api/documents", error); } } diff --git a/suite/app/api/health/route.test.ts b/suite/app/api/health/route.test.ts new file mode 100644 index 00000000..52092858 --- /dev/null +++ b/suite/app/api/health/route.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { beforeEach, expect, test, vi } from "vitest"; + +let client: unknown = null; + +vi.mock("@/lib/documents/supabaseStore", () => ({ adminDocumentsClient: () => client })); +vi.mock("@/lib/env", () => ({ + accountsConfigured: () => client !== null, + env: () => ({ NEXT_PUBLIC_SENTRY_DSN: undefined }), +})); + +// What Next's `env` option inlines at build time; vitest has no build to do it. +vi.stubEnv("APP_VERSION", "0.1.0"); +vi.stubEnv("GIT_COMMIT_SHA", "abc1234"); +vi.stubEnv("BUILD_TIMESTAMP", "2026-10-01T00:00:00.000Z"); +vi.stubEnv("APP_ENV", "production"); + +const { GET } = await import("./route"); + +const request = () => new Request("http://localhost/api/health", { headers: { "x-request-id": "health-1" } }); + +/** A Supabase client whose one query answers with `error`. */ +const answering = (error: unknown) => { + const query = { select: () => query, limit: async () => ({ error }) }; + return { from: () => query }; +}; + +beforeEach(() => { + client = null; +}); + +test("a deployment with no backend is healthy, not degraded", async () => { + const response = await GET(request()); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = await response.json(); + expect(body.status).toBe("ok"); + expect(body.checks).toEqual({ + database: "not_configured", + accounts: "not_configured", + errorReporting: "not_configured", + }); +}); + +test("a database that answers is ok", async () => { + client = answering(null); + const response = await GET(request()); + expect(response.status).toBe(200); + expect((await response.json()).checks.database).toBe("ok"); +}); + +test("a database that does not answer is a 503 an uptime monitor will see", async () => { + client = answering({ message: "TypeError: fetch failed" }); + const response = await GET(request()); + expect(response.status).toBe(503); + const body = await response.json(); + expect(body.status).toBe("degraded"); + expect(body.checks.database).toBe("down"); +}); + +test("the build is named, and nothing about the configuration but whether it exists", async () => { + client = answering(null); + const body = await (await GET(request())).json(); + expect(body).toMatchObject({ + version: "0.1.0", + commit: "abc1234", + builtAt: "2026-10-01T00:00:00.000Z", + environment: "production", + }); + expect(JSON.stringify(body)).not.toContain("supabase"); +}); diff --git a/suite/app/api/health/route.ts b/suite/app/api/health/route.ts new file mode 100644 index 00000000..60172c44 --- /dev/null +++ b/suite/app/api/health/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { build } from "@/lib/build"; +import { accountsConfigured, env } from "@/lib/env"; +import { adminDocumentsClient } from "@/lib/documents/supabaseStore"; +import { requestLog } from "@/lib/server/log"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Database = "ok" | "down" | "not_configured"; + +/** + * For an uptime monitor, and for a person asking which build is live. + * + * 200 when everything this deployment is configured to use answers, 503 when + * something does not. A deployment with no backend is healthy: local-only is a + * supported way to run this, not a degraded one. + * + * Says what is configured, never with what: no URL, key or row leaves here. + */ +export async function GET(request: Request) { + const database = await databaseStatus(request); + const status = database === "down" ? "degraded" : "ok"; + + return NextResponse.json( + { + status, + version: build.version, + commit: build.commit, + builtAt: build.builtAt, + environment: build.environment, + checks: { + database, + accounts: accountsConfigured() ? "configured" : "not_configured", + errorReporting: env().NEXT_PUBLIC_SENTRY_DSN ? "configured" : "not_configured", + }, + }, + { status: status === "ok" ? 200 : 503, headers: { "Cache-Control": "no-store" } }, + ); +} + +/** + * A head-only read of the table every account wedding lives in: proves the + * database answers and the service key is accepted, and returns no rows. + */ +async function databaseStatus(request: Request): Promise { + const client = adminDocumentsClient(); + if (!client) return "not_configured"; + + const { error } = await client.from("wedding_documents").select("wedding_id", { head: true }).limit(1); + if (!error) return "ok"; + + requestLog(request).warn({ err: error }, "[health] database check failed"); + return "down"; +} diff --git a/suite/app/api/library/[id]/route.ts b/suite/app/api/library/[id]/route.ts index cc5bd545..956afabe 100644 --- a/suite/app/api/library/[id]/route.ts +++ b/suite/app/api/library/[id]/route.ts @@ -5,14 +5,15 @@ import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { libraryStore } from "@/lib/library/supabaseStore"; import { getHandler, removeHandler } from "@/lib/library/handlers"; import { check } from "@/lib/server/check"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const missing = () => NextResponse.json({ error: "That is not in your library." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[library] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[library] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -26,7 +27,7 @@ async function owner(): Promise<{ id: string; client: NonNullable }) { +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const who = await owner(); if (who instanceof NextResponse) return who; @@ -35,11 +36,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const reply = await getHandler(libraryStore(who.client), who.id, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/library/[id]", error); + return failed(request, "GET /api/library/[id]", error); } } -export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const who = await owner(); if (who instanceof NextResponse) return who; @@ -48,6 +49,6 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ const reply = await removeHandler(libraryStore(who.client), who.id, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("DELETE /api/library/[id]", error); + return failed(request, "DELETE /api/library/[id]", error); } } diff --git a/suite/app/api/library/route.ts b/suite/app/api/library/route.ts index ba6fe302..9ce70b8f 100644 --- a/suite/app/api/library/route.ts +++ b/suite/app/api/library/route.ts @@ -4,19 +4,20 @@ import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { libraryStore } from "@/lib/library/supabaseStore"; import { listHandler, saveHandler } from "@/lib/library/handlers"; import { allow, LIBRARY_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); -const failed = (where: string, error: unknown) => { - console.error(`[library] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[library] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; /** What this account has kept, newest first. */ -export async function GET() { +export async function GET(request: Request) { try { if (!accountsConfigured()) return unconfigured(); const user = await currentUser(); @@ -26,7 +27,7 @@ export async function GET() { const reply = await listHandler(libraryStore(client), user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/library", error); + return failed(request, "GET /api/library", error); } } @@ -44,6 +45,6 @@ export async function POST(request: Request) { const reply = await saveHandler(libraryStore(client), user.id, await request.json().catch(() => null)); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("POST /api/library", error); + return failed(request, "POST /api/library", error); } } diff --git a/suite/app/api/share/[token]/route.ts b/suite/app/api/share/[token]/route.ts index 6b407ef2..308d06f0 100644 --- a/suite/app/api/share/[token]/route.ts +++ b/suite/app/api/share/[token]/route.ts @@ -4,6 +4,7 @@ import { serverClient } from "@/lib/accounts/serverClient"; import { check } from "@/lib/server/check"; import { tokenSchema } from "@/lib/share/schemas"; import { shareStore } from "@/lib/share/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -13,7 +14,7 @@ export const dynamic = "force-dynamic"; * whose wedding it is. Anyone with the token may ask; only the key after the * `#` opens it. */ -export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) { return NextResponse.json({ error: "Guest links are not set up on this deployment." }, { status: 501 }); @@ -27,7 +28,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ tok if (!sealed) return NextResponse.json({ error: "This link is not live." }, { status: 404 }); return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } }); } catch (error) { - console.error("[share] GET /api/share/[token]", error); + requestLog(request).error({ err: error }, "[share] GET /api/share/[token]"); return NextResponse.json({ error: "Something went wrong." }, { status: 500 }); } } diff --git a/suite/app/api/share/route.ts b/suite/app/api/share/route.ts index 2a2dffe4..fdb14c3f 100644 --- a/suite/app/api/share/route.ts +++ b/suite/app/api/share/route.ts @@ -6,6 +6,7 @@ import { check } from "@/lib/server/check"; import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit"; import { publishSchema, takeDownSchema } from "@/lib/share/schemas"; import { shareStore } from "@/lib/share/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -15,8 +16,8 @@ const unconfigured = () => const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 }); /** An uncaught throw becomes an HTML 500 the page cannot read. */ -const failed = (where: string, error: unknown) => { - console.error(`[share] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[share] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -33,7 +34,7 @@ export async function GET(request: Request) { if (!weddingId) return notYours(); return NextResponse.json({ link: await shareStore(client).linkOf(weddingId) }); } catch (error) { - return failed("GET /api/share", error); + return failed(request, "GET /api/share", error); } } @@ -60,7 +61,7 @@ export async function PUT(request: Request) { if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 }); return NextResponse.json(published); } catch (error) { - return failed("PUT /api/share", error); + return failed(request, "PUT /api/share", error); } } @@ -80,6 +81,6 @@ export async function DELETE(request: Request) { await shareStore(client).takeDown(input.value.weddingId); return NextResponse.json({}); } catch (error) { - return failed("DELETE /api/share", error); + return failed(request, "DELETE /api/share", error); } } diff --git a/suite/app/api/suppliers/[token]/route.ts b/suite/app/api/suppliers/[token]/route.ts index 16e5454f..fc86d8a4 100644 --- a/suite/app/api/suppliers/[token]/route.ts +++ b/suite/app/api/suppliers/[token]/route.ts @@ -5,19 +5,20 @@ import { check } from "@/lib/server/check"; import { allow, CONFIRM_LIMIT } from "@/lib/server/rateLimit"; import { tokenSchema } from "@/lib/suppliers/schemas"; import { supplierStore } from "@/lib/suppliers/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Supplier links are not set up on this deployment." }, { status: 501 }); const gone = () => NextResponse.json({ error: "This link is not live." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[suppliers] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[suppliers] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; /** What a supplier's link fetches: their sheet, sealed, and when they confirmed it. */ -export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) return unconfigured(); const token = check(tokenSchema, (await params).token); @@ -28,12 +29,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ tok if (!sealed) return gone(); return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } }); } catch (error) { - return failed("GET /api/suppliers/[token]", error); + return failed(request, "GET /api/suppliers/[token]", error); } } /** The supplier says they have it: when, recorded against their link and nothing else. */ -export async function POST(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) return unconfigured(); const token = check(tokenSchema, (await params).token); @@ -47,6 +48,6 @@ export async function POST(_request: Request, { params }: { params: Promise<{ to if (!confirmedAt) return gone(); return NextResponse.json({ confirmedAt }); } catch (error) { - return failed("POST /api/suppliers/[token]", error); + return failed(request, "POST /api/suppliers/[token]", error); } } diff --git a/suite/app/api/suppliers/route.ts b/suite/app/api/suppliers/route.ts index 39bb9211..741921f2 100644 --- a/suite/app/api/suppliers/route.ts +++ b/suite/app/api/suppliers/route.ts @@ -6,6 +6,7 @@ import { check } from "@/lib/server/check"; import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit"; import { publishSchema, takeDownSchema } from "@/lib/suppliers/schemas"; import { supplierStore } from "@/lib/suppliers/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -13,8 +14,8 @@ export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[suppliers] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[suppliers] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -30,7 +31,7 @@ export async function GET(request: Request) { if (!weddingId) return notYours(); return NextResponse.json({ links: await supplierStore(client).linksOf(weddingId) }); } catch (error) { - return failed("GET /api/suppliers", error); + return failed(request, "GET /api/suppliers", error); } } @@ -53,7 +54,7 @@ export async function PUT(request: Request) { if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 }); return NextResponse.json(published); } catch (error) { - return failed("PUT /api/suppliers", error); + return failed(request, "PUT /api/suppliers", error); } } @@ -71,6 +72,6 @@ export async function DELETE(request: Request) { await supplierStore(client).takeDown(input.value.weddingId, input.value.teamId); return NextResponse.json({}); } catch (error) { - return failed("DELETE /api/suppliers", error); + return failed(request, "DELETE /api/suppliers", error); } } diff --git a/suite/app/auth/callback/route.ts b/suite/app/auth/callback/route.ts index 154e143d..72f90a36 100644 --- a/suite/app/auth/callback/route.ts +++ b/suite/app/auth/callback/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { serverClient } from "@/lib/accounts/serverClient"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { firstSignInHandler } from "@/lib/accounts/handlers"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -82,7 +83,7 @@ export async function GET(request: Request) { } } catch (error) { - console.error("[accounts] GET /auth/callback", error); + requestLog(request).error({ err: error }, "[accounts] GET /auth/callback"); failed = true; } @@ -93,7 +94,7 @@ export async function GET(request: Request) { try { await firstSignInHandler(accountsStore(client), userId); } catch (error) { - console.error("[accounts] GET /auth/callback: starting a wedding", error); + requestLog(request).error({ err: error }, "[accounts] GET /auth/callback: starting a wedding"); } } diff --git a/suite/instrumentation-client.ts b/suite/instrumentation-client.ts index 61a7068b..494df6da 100644 --- a/suite/instrumentation-client.ts +++ b/suite/instrumentation-client.ts @@ -1,5 +1,11 @@ +import { build } from "@/lib/build"; +import { sentryBuild } from "@/lib/sentry/build"; import { scrubEvent } from "@/lib/sentry/scrub"; +// Which build this tab is running, for anyone with the console open — a +// support conversation's first question, answerable without a deploy log. +window.appVersion = build; + /** * Error reporting in the browser, off unless a DSN is configured. * @@ -21,6 +27,7 @@ if (dsn) { void import("@sentry/nextjs").then((Sentry) => Sentry.init({ dsn, + ...sentryBuild, sendDefaultPii: false, // A report is sent only when something breaks. tracesSampleRate: 0, diff --git a/suite/instrumentation.ts b/suite/instrumentation.ts index 425576c7..56cdd6f0 100644 --- a/suite/instrumentation.ts +++ b/suite/instrumentation.ts @@ -1,4 +1,7 @@ import * as Sentry from "@sentry/nextjs"; +import type { Instrumentation } from "next"; +import { log, REQUEST_ID_HEADER } from "@/lib/server/log"; +import { sentryBuild } from "@/lib/sentry/build"; import { scrubEvent } from "@/lib/sentry/scrub"; /** @@ -27,10 +30,26 @@ export async function register() { Sentry.init({ dsn, + ...sentryBuild, sendDefaultPii: false, tracesSampleRate: 0, beforeSend: (event) => scrubEvent(event), }); } -export const onRequestError = Sentry.captureRequestError; +/** + * A throw nothing caught. Logged with its request id and reported with it as a + * tag, so the log line, the Sentry event and the `X-Request-ID` a person saw + * are one search apart. + * + * The route template (`/api/share/[token]`) rather than the path, which can + * carry a token. + */ +export const onRequestError: Instrumentation.onRequestError = (error, request, context) => { + const requestId = request.headers[REQUEST_ID_HEADER]; + log.error({ err: error, requestId, route: context.routePath }, "unhandled request error"); + Sentry.withScope((scope) => { + scope.setTag("request_id", String(requestId)); + Sentry.captureRequestError(error, request, context); + }); +}; diff --git a/suite/lib/build.ts b/suite/lib/build.ts new file mode 100644 index 00000000..112acfef --- /dev/null +++ b/suite/lib/build.ts @@ -0,0 +1,25 @@ +/** + * Which build this is: what a bug report needs first, and what nothing in the + * running app could otherwise tell you. + * + * Computed once by `buildInfo()` in `next.config.ts` and inlined by Next's + * `env` option, so the server, the proxy and the browser all read the same + * strings. Safe to import anywhere, the browser included. + */ +export const build = { + /** `version` from this workspace's package.json. */ + version: process.env.APP_VERSION as string, + /** Short commit hash. */ + commit: process.env.GIT_COMMIT_SHA as string, + /** ISO time the build started. */ + builtAt: process.env.BUILD_TIMESTAMP as string, + /** `production`, `preview` or `development` on Vercel; NODE_ENV elsewhere. */ + environment: process.env.APP_ENV as string, +}; + +declare global { + interface Window { + /** Set by `instrumentation-client.ts` on every page. */ + appVersion: typeof build; + } +} diff --git a/suite/lib/headers.test.ts b/suite/lib/headers.test.ts index 6542b85a..053699dc 100644 --- a/suite/lib/headers.test.ts +++ b/suite/lib/headers.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { contentSecurityPolicy, securityHeaders, sentryOrigin } from "../next.config"; +import { buildHeaders, buildInfo, contentSecurityPolicy, securityHeaders, sentryOrigin } from "../next.config"; const value = (key: string) => securityHeaders.find((header) => header.key === key)?.value; @@ -73,3 +73,15 @@ test("HSTS is stated by the app rather than left to the host", () => { expect(value("Strict-Transport-Security")).toContain("max-age=63072000"); expect(value("Strict-Transport-Security")).toContain("includeSubDomains"); }); + +test("every response names the build that served it", () => { + const version = buildHeaders.find((header) => header.key === "X-App-Version")?.value; + const commit = buildHeaders.find((header) => header.key === "X-Commit-SHA")?.value; + expect(version).toMatch(/^\d+\.\d+\.\d+/); + expect(commit).toMatch(/^[0-9a-f]{7}$/); +}); + +test("the build time is fixed once, so every process of one build agrees", () => { + expect(buildInfo().BUILD_TIMESTAMP).toBe(buildInfo().BUILD_TIMESTAMP); + expect(new Date(buildInfo().BUILD_TIMESTAMP).toISOString()).toBe(buildInfo().BUILD_TIMESTAMP); +}); diff --git a/suite/lib/sentry/build.ts b/suite/lib/sentry/build.ts new file mode 100644 index 00000000..36eeafc2 --- /dev/null +++ b/suite/lib/sentry/build.ts @@ -0,0 +1,14 @@ +import { build } from "@/lib/build"; + +/** + * Which build a report came from, spread into both `Sentry.init` calls so the + * browser and the server cannot describe the same deploy differently. + * + * The release is what Sentry groups regressions by ("first seen in"); the tags + * make the version and commit filterable on every event. + */ +export const sentryBuild = { + release: `trousseau-suite@${build.version}+${build.commit}`, + environment: build.environment, + initialScope: { tags: { app_version: build.version, commit: build.commit } }, +}; diff --git a/suite/lib/server/log.test.ts b/suite/lib/server/log.test.ts new file mode 100644 index 00000000..b72ad4ad --- /dev/null +++ b/suite/lib/server/log.test.ts @@ -0,0 +1,8 @@ +// @vitest-environment node +import { expect, test } from "vitest"; +import { requestLog } from "./log"; + +test("every line a request logs carries that request's id", () => { + const request = new Request("http://localhost/api/x", { headers: { "x-request-id": "req-42" } }); + expect(requestLog(request).bindings()).toEqual({ requestId: "req-42" }); +}); diff --git a/suite/lib/server/log.ts b/suite/lib/server/log.ts new file mode 100644 index 00000000..f297f833 --- /dev/null +++ b/suite/lib/server/log.ts @@ -0,0 +1,33 @@ +import pino from "pino"; +import { build } from "@/lib/build"; + +/** + * The server's log: one JSON object per line on stdout, which is what Vercel's + * log view and every log drain parse into searchable fields. + * + * Every line names the build that wrote it, so a log line and a Sentry report + * can be matched to a commit without guessing which deploy was live. + * + * Log what happened, never what it happened to: a guest list is the one thing + * this app exists to keep on the device, and an error object is the only thing + * logged here that was not written by hand. + * + * `LOG_LEVEL` is read here rather than through `env()`, so that the log works + * when the environment does not — which is when it is needed most. Pino throws + * on a level it does not know, so a typo still fails at boot. + */ +export const log = pino({ + level: process.env.LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug"), + // Labels rather than pino's numbers, which no log viewer shows as levels. + formatters: { level: (label) => ({ level: label }) }, + timestamp: pino.stdTimeFunctions.isoTime, + base: { version: build.version, commit: build.commit, env: build.environment }, +}); + +/** Set on every request by `proxy.ts`, and echoed on every response. */ +export const REQUEST_ID_HEADER = "x-request-id"; + +/** The log, for one request: every line it writes carries that request's id. */ +export function requestLog(request: Request): pino.Logger { + return log.child({ requestId: request.headers.get(REQUEST_ID_HEADER) }); +} diff --git a/suite/next.config.ts b/suite/next.config.ts index 090d7f8d..cd5377c1 100644 --- a/suite/next.config.ts +++ b/suite/next.config.ts @@ -1,14 +1,42 @@ +import { execSync } from "node:child_process"; import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { env } from "./lib/env"; +import { version } from "./package.json"; // Checked here so a misconfigured deploy fails the build rather than answering // 501 at runtime and looking like a deliberate local-only one. Called for the -// throw, not the value. +// throw, not the value. `next start` evaluates this file too, so a self-hosted +// server started in a different environment from its build refuses to boot. env(); const development = process.env.NODE_ENV === "development"; +/** + * Which build this is, inlined into every bundle as `process.env.*` and read + * through `lib/build.ts`. + */ +export function buildInfo() { + return { + APP_VERSION: version, + // Vercel hands the build its commit as `VERCEL_GIT_COMMIT_SHA`. Everywhere + // else — CI, a laptop, a self-hosted box, `vercel build` — git is asked, + // and a build that cannot say which commit it is fails here. + GIT_COMMIT_SHA: ( + process.env.VERCEL_GIT_COMMIT_SHA ?? execSync("git rev-parse HEAD", { encoding: "utf8" }) + ) + .trim() + .slice(0, 7), + // Next evaluates this file in more than one process during a build. The + // first evaluation fixes the time in the environment and the later ones + // inherit it, so the server and the browser cannot disagree about it. + BUILD_TIMESTAMP: (process.env.BUILD_TIMESTAMP ??= new Date().toISOString()), + APP_ENV: process.env.VERCEL_ENV ?? process.env.NODE_ENV, + }; +} + +const info = buildInfo(); + /** * Sentry's ingest origin, when there is one. * @@ -127,13 +155,24 @@ const securityHeaders = [ }, ]; +/** Which build answered, on every response — the first question about any bug. */ +const buildHeaders = [ + { key: "X-App-Version", value: info.APP_VERSION }, + { key: "X-Commit-SHA", value: info.GIT_COMMIT_SHA }, +]; + const nextConfig: NextConfig = { + env: info, + // Browser source maps, served alongside the bundles. This is AGPL software + // whose source is already public, so they reveal nothing new, and Sentry + // fetches them from the deployment to turn minified frames back into lines. + productionBrowserSourceMaps: true, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [{ source: "/:path*", headers: [...securityHeaders, ...buildHeaders] }]; }, }; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" }); export default withBundleAnalyzer(nextConfig); -export { contentSecurityPolicy, securityHeaders }; +export { buildHeaders, contentSecurityPolicy, securityHeaders }; diff --git a/suite/package.json b/suite/package.json index edc315fa..66728fa8 100644 --- a/suite/package.json +++ b/suite/package.json @@ -30,6 +30,7 @@ "lucide-react": "^1.47.0", "next": "16.3.5", "pdf-lib": "^1.17.1", + "pino": "^10.3.1", "react": "19.3.0", "react-dom": "19.3.0", "svg2pdf.js": "^2.8.1", diff --git a/suite/proxy.test.ts b/suite/proxy.test.ts new file mode 100644 index 00000000..ad4e17ac --- /dev/null +++ b/suite/proxy.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { NextRequest } from "next/server"; +import { expect, test } from "vitest"; +import { proxy } from "./proxy"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const at = (headers: Record = {}, path = "/api/health") => + new NextRequest(`http://localhost${path}`, { headers }); + +/** What a route handler behind the proxy will read as `x-request-id`. */ +const forwarded = (response: Response) => response.headers.get("x-middleware-request-x-request-id"); + +test("a request without an id is given one, echoed and forwarded", async () => { + const response = await proxy(at()); + const id = response.headers.get("x-request-id"); + expect(id).toMatch(UUID); + expect(forwarded(response)).toBe(id); +}); + +test("a caller's id is kept, so a trace crosses from their system into ours", async () => { + const response = await proxy(at({ "x-request-id": "edge-abc.123:9" })); + expect(response.headers.get("x-request-id")).toBe("edge-abc.123:9"); + expect(forwarded(response)).toBe("edge-abc.123:9"); +}); + +test("X-Correlation-ID is accepted when X-Request-ID is absent", async () => { + const response = await proxy(at({ "x-correlation-id": "corr-1" })); + expect(response.headers.get("x-request-id")).toBe("corr-1"); +}); + +test("an id that could write arbitrary text into the log is replaced", async () => { + const response = await proxy(at({ "x-request-id": 'x" injected=1' })); + expect(response.headers.get("x-request-id")).toMatch(UUID); +}); + +test("an over-long id is replaced", async () => { + const response = await proxy(at({ "x-request-id": "a".repeat(129) })); + expect(response.headers.get("x-request-id")).toMatch(UUID); +}); + +test("a sign-in redirect carries the id too", async () => { + const response = await proxy(at({ "x-request-id": "signin-1" }, "/?code=abc")); + expect(response.status).toBe(307); + expect(response.headers.get("x-request-id")).toBe("signin-1"); + // And the code is still stripped, as before. + expect(response.headers.get("location")).not.toContain("code="); +}); diff --git a/suite/proxy.ts b/suite/proxy.ts index c16c3953..286bda37 100644 --- a/suite/proxy.ts +++ b/suite/proxy.ts @@ -1,5 +1,6 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { log, REQUEST_ID_HEADER } from "@/lib/server/log"; /** * Finish a sign-in wherever its link happens to land. @@ -19,14 +20,47 @@ import { NextResponse, type NextRequest } from "next/server"; const CLEAN = ["code", "token_hash", "type"]; +/** + * What an incoming request id may look like. A caller's id is kept so a trace + * can cross from their system into this one; anything else is replaced, so a + * header cannot write arbitrary text into the log. + */ +const REQUEST_ID = /^[\w.:-]{1,128}$/; + +function requestId(request: NextRequest): string { + const presented = request.headers.get(REQUEST_ID_HEADER) ?? request.headers.get("x-correlation-id"); + return presented && REQUEST_ID.test(presented) ? presented : crypto.randomUUID(); +} + +/** + * Every request gets an id, which the route handlers read back to tag their + * log lines (`requestLog`) and which goes back to the caller on the response, + * so a report from a browser can be found in the log. + */ export async function proxy(request: NextRequest) { - const code = request.nextUrl.searchParams.get("code"); - const tokenHash = request.nextUrl.searchParams.get("token_hash"); + const id = requestId(request); + const response = signsIn(request) ? await finishSignIn(request, id) : forward(request, id); + response.headers.set(REQUEST_ID_HEADER, id); + return response; +} + +function forward(request: NextRequest, id: string): NextResponse { + const headers = new Headers(request.headers); + headers.set(REQUEST_ID_HEADER, id); + return NextResponse.next({ request: { headers } }); +} +function signsIn(request: NextRequest): boolean { + const { searchParams, pathname } = request.nextUrl; // The overwhelmingly common case: no auth material, nothing to do. - if (!code && !tokenHash) return NextResponse.next(); + if (!searchParams.get("code") && !searchParams.get("token_hash")) return false; // The callback route does its own exchange, and knows about `next`. - if (request.nextUrl.pathname === "/auth/callback") return NextResponse.next(); + return pathname !== "/auth/callback"; +} + +async function finishSignIn(request: NextRequest, id: string): Promise { + const code = request.nextUrl.searchParams.get("code"); + const tokenHash = request.nextUrl.searchParams.get("token_hash"); const destination = new URL(request.nextUrl); for (const key of CLEAN) destination.searchParams.delete(key); @@ -59,7 +93,7 @@ export async function proxy(request: NextRequest) { response.headers.set("location", destination.toString()); } } catch (error) { - console.error("[accounts] proxy sign-in", error); + log.child({ requestId: id }).error({ err: error }, "[accounts] proxy sign-in"); destination.searchParams.set("signin", "failed"); response.headers.set("location", destination.toString()); }