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:
32 files changed
+7533
-1
No files matched your search
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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('; ');
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user