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

+25
View File
@@ -0,0 +1,25 @@
# Copier vers .env et renseigner. Ne jamais committer .env.
# --- Base de données --------------------------------------------------------
POSTGRES_USER=planflow
POSTGRES_PASSWORD=change-me
POSTGRES_DB=planflow
# Utilisée par l'application et par Prisma.
# En docker-compose l'hôte est `db` ; en développement local, `localhost`.
DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
# --- Chiffrement ------------------------------------------------------------
# Chiffre au repos les colonnes sensibles exigées par PLAN.md §3.6 :
# NIR, IBAN, BIC. 32 octets en base64.
#
# openssl rand -base64 32
#
# Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à
# lire ces colonnes. La perdre rend les données chiffrées irrécupérables —
# la sauvegarder séparément et documenter sa rotation.
ENCRYPTION_KEY=
# --- Application ------------------------------------------------------------
APP_URL=http://localhost:3000
APP_PORT=3000
+71
View File
@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Test-only values. Real secrets never live in CI configuration.
DATABASE_URL: postgresql://planflow:planflow@localhost:5432/planflow_test
ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_URL: http://127.0.0.1:3100
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: planflow
POSTGRES_PASSWORD: planflow
POSTGRES_DB: planflow_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U planflow"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Appliquer les migrations
run: pnpm db:deploy
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- name: Installer le navigateur Playwright
run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+23
View File
@@ -0,0 +1,23 @@
node_modules/
.pnpm-store/
.next/
out/
build/
next-env.d.ts
*.tsbuildinfo
.env
.env.*
!.env.example
coverage/
test-results/
playwright-report/
blob-report/
.playwright/
.DS_Store
*.log
.tmp/
.pgdata/
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
# ---- dependencies -----------------------------------------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile
# ---- build ------------------------------------------------------------------
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm db:generate && pnpm build
# ---- runtime ----------------------------------------------------------------
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# `output: standalone` emits a server bundle carrying only the modules it uses.
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
# Migrations run at startup, so the image can be deployed without a separate
# migration step. prisma/ and its CLI are needed for that.
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node server.js"]
+13 -1
View File
@@ -60,9 +60,21 @@ Huit constats structurants, qu'une lecture de la documentation publique de Combo
| **Paie** | **Export vers Silae** (§8). Aucun moteur de paie, aucun bulletin, aucune DSN dans PlanFlow. |
| **Pointeuse** | **Hors périmètre.** Pas de borne, pas de PWA kiosque, pas de pointage matériel. Les heures réelles sont saisies et validées par le manager (§7.3). |
| **Convention d'amorce** | **IDCC 1517**, moteur paramétrable pour en ajouter d'autres. |
| **Stack** | Next.js 15 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma · Auth.js v5 · Tailwind + shadcn/ui · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
| **Stack** | Next.js 16 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma 7 · **sessions maison en base** · Tailwind 4 · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
| **Mobile** | PWA installable, responsive. Pas d'application native. |
### Écarts constatés à la réalisation
Trois choix de §2 ont été révisés au WP-00, après confrontation aux versions réellement disponibles.
| Sujet | Plan initial | Retenu | Raison |
|---|---|---|---|
| Next.js | 15 | **16.3** | Version stable courante. Démarrer une majeure en retard n'apporte rien. |
| Authentification | Auth.js v5 | **Sessions en base** | Auth.js v5 est toujours en beta. Le besoin se limite à identifiants et invitation, sans OAuth — et la matrice n° 23 impose la **révocation de session**, immédiate avec des sessions en base, malaisée avec des JWT. |
| Convention | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
Prisma 7 a par ailleurs déplacé l'URL de connexion du schéma vers `prisma.config.ts`, et l'application passe désormais par un adaptateur `pg` explicite — c'est ce point d'accroche qui recevra l'extension de scoping multi-tenant au WP-01.
**Hors périmètre v1**, à ne pas construire : moteur de paie, DSN, bulletins de paie, distribution de bulletins, signature électronique qualifiée, transmission DPAE à l'URSSAF, connecteurs de caisse, abonnement et facturation, planning prédictif, auto-assignation.
**Conservés mais différés en fin de parcours** : articles et conversations internes (WP-11, optionnel). Les analyses RH sont bien dans le périmètre v1 (WP-09).
+97
View File
@@ -0,0 +1,97 @@
# PlanFlow
Gestion du personnel, des plannings et des temps, multi-établissements, auto-hébergée. La paie est **exportée vers Silae** ; PlanFlow ne produit ni bulletin ni DSN.
La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative : en cas d'écart entre le code et le plan, c'est le plan qui a raison, ou le plan qui doit être corrigé — jamais l'écart qui s'installe.
| Document | Rôle |
|---|---|
| [`PLAN.md`](PLAN.md) | Spécification : périmètre, modèle de données, règles, lots de travail |
| [`matrice-conformite-rh-france-2026.md`](matrice-conformite-rh-france-2026.md) | Exigences réglementaires françaises, cotées P0/P1/P2 |
| [`Audit Combo/`](Audit%20Combo/INDEX.md) | Audit fonctionnel du produit de référence |
## État
**WP-00 — socle.** Next.js, Prisma, base de données, en-têtes de sécurité, tests, CI, image Docker. Aucun écran métier : ils arrivent à partir de WP-01 (tenancy, identité, autorisation).
## Démarrer
### Avec Docker
```bash
cp .env.example .env
# Renseigner POSTGRES_PASSWORD et ENCRYPTION_KEY (voir ci-dessous)
docker compose up --build
```
L'application écoute sur <http://localhost:3000>. Les migrations s'appliquent au démarrage du conteneur.
### En local
Nécessite Node 22, pnpm 10 et un PostgreSQL 16 accessible.
```bash
pnpm install
cp .env.example .env # renseigner DATABASE_URL et ENCRYPTION_KEY
pnpm db:generate
pnpm db:deploy
pnpm dev
```
### Clé de chiffrement
`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) : NIR, IBAN, BIC.
```bash
openssl rand -base64 32
```
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation.
## Vérifier
```bash
pnpm verify # typecheck + lint + tests unitaires
pnpm test:e2e # build, serveur standalone, tests de bout en bout
```
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
## Choix structurants
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
- une Content-Security-Policy qui ne nomme **aucune** origine externe, posée par requête avec un nonce (`src/proxy.ts`) ;
- un test qui échoue si une dépendance de traçage apparaît dans `package.json`.
Pour de la télémétrie technique, passer par une interface abstraite auto-hébergée.
**Le serveur testé est celui qui est déployé.** Les tests end-to-end lancent le serveur `standalone`, celui que l'image Docker exécute — pas `next dev`, dont la politique de sécurité est volontairement plus permissive.
## Structure
```
src/
├── app/ écrans (App Router)
├── lib/
│ ├── env.ts contrat d'environnement, validé à l'import
│ └── security/csp.ts politique de sécurité, fonction pure et testable
├── server/
│ ├── db.ts client Prisma — le scoping multi-tenant s'y greffe au WP-01
│ └── health.ts
└── proxy.ts en-têtes de sécurité par requête
prisma/ schéma et migrations
tests/
├── unit/ Vitest
└── e2e/ Playwright
```
## Écarts assumés par rapport au plan
Trois choix diffèrent de ce qu'annonçait `PLAN.md` §2, et le plan a été mis à jour en conséquence.
| Sujet | Plan initial | Retenu | Raison |
|---|---|---|---|
| Next.js | 15 | **16.3** | Version stable courante ; démarrer un greenfield une majeure en retard n'a pas de contrepartie. |
| Authentification | Auth.js v5 | **Sessions maison en base** | Auth.js v5 est encore en beta. Le besoin se limite à identifiants et invitation, sans OAuth, et la matrice de conformité (n° 23) impose la **révocation de session** — immédiate avec des sessions en base, malaisée avec des jetons JWT. |
| Convention Next | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
+42
View File
@@ -0,0 +1,42 @@
name: planflow
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD est requis}
POSTGRES_DB: ${POSTGRES_DB:-planflow}
# Deterministic collation: ordering of employee names must not depend on
# the host locale, or exports differ between machines.
LANG: C.UTF-8
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
interval: 5s
timeout: 5s
retries: 10
# Not published by default: nothing outside the compose network needs the
# database, and an HR dataset should not be one firewall rule from the world.
expose:
- '5432'
app:
build:
context: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER:-planflow}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planflow}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis — voir .env.example}
APP_URL: ${APP_URL:-http://localhost:3000}
ports:
- '${APP_PORT:-3000}:3000'
volumes:
db-data:
+33
View File
@@ -0,0 +1,33 @@
import coreWebVitals from 'eslint-config-next/core-web-vitals';
import typescript from 'eslint-config-next/typescript';
/**
* eslint-config-next 16 ships flat configs directly, so no FlatCompat bridge.
*/
const config = [
{
ignores: [
'node_modules/**',
'.next/**',
'next-env.d.ts',
'playwright-report/**',
'test-results/**',
'.pgdata/**',
],
},
...coreWebVitals,
...typescript,
{
rules: {
// An unused binding is usually a leftover, and a leftover in an
// authorisation path is a security bug. The _ prefix marks the ones that
// are deliberate.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
];
export default config;
+35
View File
@@ -0,0 +1,35 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Docker image: ship only what the server needs.
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
// Security headers that do not need a per-request nonce.
// The Content-Security-Policy is set in src/proxy.ts because it does.
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// No browser feature in this app needs these. Denying them keeps a
// future dependency from silently reaching for a camera or a GPS fix.
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
},
],
},
];
},
};
export default nextConfig;
+64
View File
@@ -0,0 +1,64 @@
{
"name": "planflow",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"verify": "pnpm typecheck && pnpm lint && pnpm test"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@node-rs/argon2": "2.0.2",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"next": "16.3.0",
"pg": "8.22.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "4.4.3"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@tailwindcss/postcss": "4.3.3",
"@types/node": "22.19.4",
"@types/pg": "8.20.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"dotenv": "^17.4.2",
"eslint": "9.39.1",
"eslint-config-next": "16.3.0",
"prisma": "7.9.1",
"tailwindcss": "4.3.3",
"tsx": "4.20.6",
"typescript": "5.9.3",
"vitest": "4.1.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma",
"unrs-resolver"
]
}
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from '@playwright/test';
const PORT = Number(process.env.E2E_PORT ?? 3100);
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
// Serial in CI so the single Postgres service is not raced.
...(process.env.CI ? { workers: 1 } : {}),
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
// Exercise the standalone server, which is what the Docker image runs.
// `next start` is not compatible with `output: standalone`, and testing a
// different server than the one deployed defeats the purpose of this suite.
command: [
'pnpm build',
'cp -r .next/static .next/standalone/.next/static',
'cp -r public .next/standalone/public',
`node .next/standalone/server.js`,
].join(' && '),
url: baseURL,
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' },
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
+6462
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
/**
* Prisma 7 moved the migration connection URL out of schema.prisma.
*
* Only the CLI reads this file. The application connects through the pg driver
* adapter in src/server/db.ts, which is what lets WP-01 wrap every query in the
* tenant-scoping extension required by PLAN.md §3.1.
*/
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
});
@@ -0,0 +1,10 @@
-- Extensions PostgreSQL dont dépendent les lots suivants.
--
-- citext : adresses e-mail insensibles à la casse, sans dupliquer un champ
-- normalisé à côté de la valeur saisie.
-- pgcrypto: primitives utilisées par les contrôles d'intégrité (empreintes
-- d'exports, PLAN.md §8.3). Le chiffrement des colonnes sensibles
-- reste applicatif, clé hors base (PLAN.md §3.6).
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g. Git)
provider = "postgresql"
+14
View File
@@ -0,0 +1,14 @@
// PlanFlow — schéma de données.
//
// WP-00 n'installe que la plomberie : datasource, generator et les extensions
// PostgreSQL dont les lots suivants dépendent. Les modèles métier arrivent au
// WP-01 (tenancy, identité, autorisation) — voir PLAN.md §4.
generator client {
provider = "prisma-client-js"
}
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
datasource db {
provider = "postgresql"
}
View File
Whitespace-only changes.
+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',
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
/**
* The unit tests prove the policy builder is correct. This proves the running
* application actually sends it — a middleware matcher that quietly stops
* matching would pass every unit test and ship an unprotected app.
*/
test('les en-têtes de sécurité sont servis par l’application', async ({
request,
}) => {
const response = await request.get('/');
expect(response.status()).toBe(200);
const headers = response.headers();
const csp = headers['content-security-policy'];
expect(csp, 'aucune Content-Security-Policy servie').toBeTruthy();
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toMatch(/script-src [^;]*'nonce-/);
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['referrer-policy']).toBe('no-referrer');
expect(headers['permissions-policy']).toContain('geolocation=()');
// Next.js advertises itself by default; there is no reason to tell an
// attacker which framework and version to look up.
expect(headers['x-powered-by']).toBeUndefined();
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
buildContentSecurityPolicy,
NETWORK_DIRECTIVES,
} from '@/lib/security/csp';
function parse(policy: string): Map<string, string[]> {
return new Map(
policy.split('; ').map((directive) => {
const [name, ...values] = directive.split(' ');
return [name ?? '', values];
}),
);
}
/** Any source that is neither a keyword, a nonce, nor a safe scheme. */
function externalOrigins(values: string[]): string[] {
return values.filter((value) => {
if (value.startsWith("'")) return false; // 'self', 'none', 'nonce-…', …
if (value === 'blob:' || value === 'data:') return false;
return true;
});
}
describe('Content-Security-Policy', () => {
const policy = buildContentSecurityPolicy({ nonce: 'dGVzdC1ub25jZQ==' });
const directives = parse(policy);
it('names no external origin on any network directive', () => {
// PLAN.md §3.7 — the audited product shipped Segment, LinkedIn Ads, Google
// Ads, DoubleClick, Clarity, Hotjar and Bugsnag. This assertion is what
// keeps that from creeping back in as a "small" addition.
for (const directive of NETWORK_DIRECTIVES) {
const values = directives.get(directive) ?? [];
expect(
externalOrigins(values),
`${directive} autorise une origine tierce`,
).toEqual([]);
}
});
it('carries the request nonce on script-src', () => {
expect(directives.get('script-src')).toContain("'nonce-dGVzdC1ub25jZQ=='");
});
it('never allows unsafe-eval outside development', () => {
expect(policy).not.toContain("'unsafe-eval'");
});
it('allows unsafe-eval in development only, for React Refresh', () => {
const devPolicy = buildContentSecurityPolicy({
nonce: 'dGVzdA==',
isDevelopment: true,
});
expect(devPolicy).toContain("'unsafe-eval'");
});
it('forbids inline scripts, framing and object embedding', () => {
expect(directives.get('script-src')).not.toContain("'unsafe-inline'");
expect(directives.get('frame-ancestors')).toEqual(["'none'"]);
expect(directives.get('object-src')).toEqual(["'none'"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
/**
* The env module validates at import, so each case needs a fresh module
* registry with process.env set beforehand.
*/
async function loadEnv(values: Record<string, string | undefined>) {
const previous = { ...process.env };
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
try {
vi.resetModules();
return await import('@/lib/env');
} finally {
process.env = previous;
}
}
const VALID_KEY = Buffer.alloc(32, 7).toString('base64');
describe('configuration d’environnement', () => {
it('accepte une configuration complète', async () => {
const { env } = await loadEnv({
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
ENCRYPTION_KEY: VALID_KEY,
APP_URL: 'https://planflow.example',
});
expect(env.DATABASE_URL).toContain('planflow');
expect(env.APP_URL).toBe('https://planflow.example');
});
it('refuse une clé de chiffrement qui ne fait pas 32 octets', async () => {
await expect(
loadEnv({
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
}),
).rejects.toThrow(/ENCRYPTION_KEY/);
});
it('refuse une URL de base non PostgreSQL', async () => {
await expect(
loadEnv({
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
ENCRYPTION_KEY: VALID_KEY,
}),
).rejects.toThrow(/DATABASE_URL/);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* PLAN.md §3.7 bans analytics and advertising dependencies outright.
*
* The CSP stops such a package at runtime; this stops it at review time, with a
* message that says why. The audited product carried ten of these, so the
* failure mode is not hypothetical — it is what happens when nobody is looking.
*/
const BANNED = [
'segment',
'@segment/',
'analytics-node',
'react-ga',
'gtag',
'google-analytics',
'mixpanel',
'amplitude',
'hotjar',
'clarity-js',
'satismeter',
'fullstory',
'logrocket',
'bugsnag',
'sentry',
'datadog',
'posthog',
'heap-analytics',
'intercom',
];
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
describe('dépendances', () => {
const packageJson: PackageJson = JSON.parse(
readFileSync(
fileURLToPath(new URL('../../package.json', import.meta.url)),
'utf8',
),
);
const installed = [
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.devDependencies ?? {}),
];
it('ne contient aucun traceur publicitaire ou analytique', () => {
const offenders = installed.filter((name) =>
BANNED.some((banned) => name.toLowerCase().includes(banned)),
);
expect(
offenders,
'PLAN.md §3.7 interdit les traceurs tiers dans une application RH. ' +
"Pour de la télémétrie technique, passer par l'interface abstraite " +
'auto-hébergée plutôt que par un service externe.',
).toEqual([]);
});
});
+49
View File
@@ -0,0 +1,49 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"verbatimModuleSyntax": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
".next",
"tests/e2e"
]
}
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
globals: false,
},
});