diff --git a/docs/production-readiness.md b/docs/production-readiness.md new file mode 100644 index 0000000..67ae782 --- /dev/null +++ b/docs/production-readiness.md @@ -0,0 +1,38 @@ +# Production readiness checklist + +Tracks #59. The README WIP banner stays until every item is **Done**. + +| Item | Status | Notes | +| --- | --- | --- | +| Stable Prisma migrations | Partial | Migrations under `prisma/migrations`; deploy with `prisma migrate deploy`. No squashed baseline yet. | +| DB backups | Pending | Depends on hosting; decision deferred with the Docker prod work. | +| Secrets management (no plaintext) | Partial | Connection/provider configs encrypted at rest (AES-256-GCM, see [encryption.md](encryption.md)). Env secrets via the environment, never committed; CI secret scan enforces this. | +| Application healthcheck | Done | `GET /api/health` pings the DB; 200 `{status:"ok"}` / 503 on failure. | +| Logging policy (zero PII / secrets) | Done (policy) | See below. | +| Security headers review | Done (baseline) | See below; strict CSP still pending. | + +## Logging policy (zero PII / secrets) + +- Never log decrypted configs, API keys, bearer tokens, or `encrypted*` fields. +- Never log employee emails or names except where strictly required; prefer ids. +- Provider/sync errors log the error message only, not the credentials or the + request body. +- The healthcheck and cron endpoints return no PII. + +## Security headers + +Baseline headers are applied to every response in `next.config.ts`: +`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, +`Referrer-Policy: strict-origin-when-cross-origin`, `X-DNS-Prefetch-Control: off`, +`Permissions-Policy` (camera/mic/geolocation disabled), and +`Strict-Transport-Security` (2y, includeSubDomains, preload). + +Pending: a strict `Content-Security-Policy`. The App Router needs per-request +nonces for inline scripts/styles, so it is tracked as a follow-up rather than +shipped loose. + +## Remaining before removing the WIP banner + +- DB backup strategy (tied to the deferred Docker/prod hosting decision). +- Squashed migration baseline (optional). +- Strict CSP with nonces. diff --git a/next.config.ts b/next.config.ts index 504f336..11abe91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,25 @@ import type { NextConfig } from "next"; +// Baseline security headers applied to every response. A strict CSP needs +// per-request nonces in the App Router and is tracked separately; these cover +// the framing, sniffing, referrer, transport, and feature-policy surface. +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "X-DNS-Prefetch-Control", value: "off" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, +]; + const nextConfig: NextConfig = { serverExternalPackages: ["pg", "@prisma/adapter-pg"], + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, async redirects() { return [ { source: "/settings", destination: "/data-sources", permanent: true }, diff --git a/src/app/api/health/route.test.ts b/src/app/api/health/route.test.ts new file mode 100644 index 0000000..5333e65 --- /dev/null +++ b/src/app/api/health/route.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +const queryRaw = vi.fn() +vi.mock("@/lib/prisma", () => ({ prisma: { $queryRaw: () => queryRaw() } })) + +import { GET } from "./route" + +beforeEach(() => vi.clearAllMocks()) + +describe("GET /api/health", () => { + it("returns ok when the database is reachable", async () => { + queryRaw.mockResolvedValue([{ "?column?": 1 }]) + const res = await GET() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: "ok", db: "up" }) + }) + + it("returns 503 when the database query fails", async () => { + queryRaw.mockRejectedValue(new Error("no db")) + const res = await GET() + expect(res.status).toBe(503) + expect(await res.json()).toEqual({ status: "error", db: "down" }) + }) +}) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..4b58d6e --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +// Liveness + DB readiness probe. Unauthenticated and side-effect free: it only +// confirms the process is up and can reach the database. No PII or secrets. +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1` + return NextResponse.json({ status: "ok", db: "up" }) + } catch { + return NextResponse.json({ status: "error", db: "down" }, { status: 503 }) + } +}