WP-00: application foundation

Scaffolds the project: Next.js 16 App Router with strict TypeScript,
Prisma 7 on PostgreSQL 16, Tailwind 4, Vitest, Playwright, CI, and a
standalone Docker image that applies migrations on boot.

Makes the no-tracker rule of PLAN.md 3.7 enforceable rather than
stated. A per-request nonce-based CSP names no external origin, a unit
test fails if any network directive gains one, and a second test fails
if a tracking package appears in package.json. The end-to-end test
drives the standalone server the Docker image runs, not `next dev`,
so a proxy matcher that stopped matching could not pass unnoticed.

Environment is validated at import, so a missing DATABASE_URL fails at
boot with a readable message instead of surfacing later as a driver
error mid-export. ENCRYPTION_KEY is checked to be 32 bytes.

Three deviations from the plan, recorded in PLAN.md and README:
Next 16 rather than 15, `proxy.ts` rather than the now-deprecated
`middleware.ts`, and database-backed sessions rather than Auth.js v5,
which is still beta and whose JWTs would make the session revocation
required by compliance item 23 awkward.

Verified locally against PostgreSQL 16: migrations apply, extensions
created, typecheck, lint, 9 unit tests and the end-to-end header test
all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 17:54:11 +00:00
1 parent 21c98f3b47
commit dd639a86f5
32 files changed
+7533 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'PlanFlow',
description: 'Gestion du personnel, des plannings et des temps.',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="fr">
<body className="min-h-dvh bg-neutral-50 text-neutral-900 antialiased">
{children}
</body>
</html>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { checkDatabase } from '@/server/health';
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const database = await checkDatabase();
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-6 p-8">
<div>
<h1 className="text-3xl font-semibold tracking-tight">PlanFlow</h1>
<p className="mt-2 text-neutral-600">
Socle applicatif — lot WP-00. Les écrans métier arrivent aux lots suivants.
</p>
</div>
<dl className="grid gap-3 rounded-lg border border-neutral-200 bg-white p-6">
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-neutral-600">Base de données</dt>
<dd
className={
database.ok
? 'text-sm font-medium text-emerald-700'
: 'text-sm font-medium text-red-700'
}
>
{database.ok ? 'connectée' : `indisponible — ${database.error}`}
</dd>
</div>
</dl>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
/**
* Environment contract, validated once at import.
*
* A missing DATABASE_URL should fail at boot with a readable message, not
* surface later as an opaque driver error in the middle of a payroll export.
*/
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
/**
* Application-level encryption key for the columns PLAN.md §3.6 requires at
* rest (NIR, IBAN, BIC). 32 bytes, base64. Kept out of the database so a dump
* alone does not disclose them.
*/
ENCRYPTION_KEY: z
.string()
.refine(
(value) => Buffer.from(value, 'base64').length === 32,
'ENCRYPTION_KEY doit être 32 octets encodés en base64',
),
/** Public origin, used for links in invitation and notification e-mails. */
APP_URL: z.url().default('http://localhost:3000'),
});
export type Env = z.infer<typeof schema>;
function load(): Env {
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const details = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Configuration d'environnement invalide :\n${details}`);
}
return parsed.data;
}
export const env = load();
+67
View File
@@ -0,0 +1,67 @@
/**
* Content-Security-Policy for PlanFlow.
*
* PLAN.md §3.7 bans third-party analytics and advertising trackers. The audit of
* the reference product intercepted 2102 such requests and no business call at
* all; an HR application must not leak employee-context navigation to ad
* networks. This policy names no external origin, so any dependency that tries
* is stopped by the browser.
*
* Kept as a pure function so the rule is unit-testable without booting Next.
* Applied per request in src/proxy.ts.
*/
export interface CspOptions {
/** Per-request nonce, base64. Next.js needs it for its own inline scripts. */
nonce: string;
/** Dev needs 'unsafe-eval' for React Refresh. Never enable it in production. */
isDevelopment?: boolean;
}
/** Directives that must never name a host other than 'self'. */
export const NETWORK_DIRECTIVES = [
'default-src',
'script-src',
'connect-src',
'img-src',
'style-src',
'font-src',
'frame-src',
] as const;
export function buildContentSecurityPolicy({
nonce,
isDevelopment = false,
}: CspOptions): string {
const scriptSrc = [
"'self'",
`'nonce-${nonce}'`,
// Lets modern browsers trust scripts loaded by a nonce-verified script,
// while older ones fall back to 'self'.
"'strict-dynamic'",
...(isDevelopment ? ["'unsafe-eval'"] : []),
];
const directives: Record<string, string[]> = {
'default-src': ["'self'"],
'script-src': scriptSrc,
// Tailwind injects styles at build time, but React still sets inline
// style attributes; 'unsafe-inline' for styles carries no script risk.
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'blob:', 'data:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-src': ["'none'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'frame-ancestors': ["'none'"],
'upgrade-insecure-requests': [],
};
return Object.entries(directives)
.map(([directive, values]) =>
values.length > 0 ? `${directive} ${values.join(' ')}` : directive,
)
.join('; ');
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse, type NextRequest } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/security/csp';
/**
* Emits a per-request nonce and the Content-Security-Policy built from it.
*
* PLAN.md §3.7 forbids third-party trackers. A policy that names no external
* origin is what makes that rule enforceable rather than merely stated: a
* dependency that starts phoning home is blocked by the browser instead of
* quietly succeeding.
*
* Next.js 16 renamed the `middleware` convention to `proxy`.
*/
export function proxy(request: NextRequest): NextResponse {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = buildContentSecurityPolicy({
nonce,
isDevelopment: process.env.NODE_ENV === 'development',
});
// Server Components read the nonce from the request headers.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('content-security-policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('content-security-policy', csp);
return response;
}
export const config = {
matcher: [
// Everything except static assets, which need no policy of their own.
{
source: '/((?!_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
+31
View File
@@ -0,0 +1,31 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { env } from '@/lib/env';
/**
* Prisma client singleton.
*
* WP-01 wraps this with the tenant-scoping extension required by PLAN.md §3.1,
* so every query is filtered by the session's account and scope, with
* PostgreSQL row-level security behind it as defence in depth. Nothing outside
* this module should construct a client.
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createClient(): PrismaClient {
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
return new PrismaClient({
adapter,
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from '@/server/db';
export interface HealthResult {
ok: boolean;
error?: string;
}
/** Round-trips a trivial query so the landing page reports real connectivity. */
export async function checkDatabase(): Promise<HealthResult> {
try {
await prisma.$queryRaw`SELECT 1`;
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'erreur inconnue',
};
}
}