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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/production-readiness.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
})
})
13 changes: 13 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}