diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34a0468 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Contexte de construction. +# +# `.env` en tête et ce n'est pas un détail : il porte la clé de chiffrement et +# les mots de passe de la base. Copié dans le contexte, il finirait dans une +# couche de l'image, lisible par quiconque la récupère. +.env +.env.* +!.env.example + +node_modules +.next +.pgdata +storage +test-results +playwright-report +.git +.github +.claude + +# Documents de travail : ils alourdissent le contexte sans servir au build. +Audit Combo +design +*.md +!README.md diff --git a/src/lib/env.ts b/src/lib/env.ts index 576c507..475f795 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -51,4 +51,35 @@ function load(): Env { return parsed.data; } -export const env = load(); +let cached: Env | null = null; + +/** + * Contrat d'environnement, validé à la **première lecture** et non à l'import. + * + * La différence n'est pas cosmétique : Next.js évalue les modules serveur + * pendant la construction de l'image, où ni la base ni la clé de chiffrement + * n'existent — ce sont des valeurs d'exécution. Valider à l'import rendait + * l'image impossible à construire sans les secrets de production, c'est-à-dire + * exigeait de les confier au constructeur. + * + * La garantie reste entière : la première lecture arrive au premier appel utile, + * bien avant qu'une requête aboutisse, et échoue avec le même message. + */ +export const env: Env = new Proxy({} as Env, { + get(_target, property) { + cached ??= load(); + return cached[property as keyof Env]; + }, + has(_target, property) { + cached ??= load(); + return property in cached; + }, + ownKeys() { + cached ??= load(); + return Reflect.ownKeys(cached); + }, + getOwnPropertyDescriptor(_target, property) { + cached ??= load(); + return Reflect.getOwnPropertyDescriptor(cached, property); + }, +}); diff --git a/src/server/db.ts b/src/server/db.ts index b5ae30f..cc66883 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -24,8 +24,28 @@ function createClient(): PrismaClient { }); } -export const prisma = globalForPrisma.prisma ?? createClient(); - -if (env.NODE_ENV !== 'production') { - globalForPrisma.prisma = prisma; +/** + * Client construit à la **première utilisation**, pas à l'import. + * + * Next.js évalue les modules serveur pendant la construction de l'image, où + * aucune base n'existe : ouvrir la connexion à l'import rendait l'image + * impossible à construire sans une base joignable et sans les secrets de + * production. Une connexion est un objet d'exécution. + */ +function client(): PrismaClient { + globalForPrisma.prisma ??= createClient(); + return globalForPrisma.prisma; } + +export const prisma: PrismaClient = new Proxy({} as PrismaClient, { + get(_target, property) { + const instance = client(); + const value = Reflect.get(instance, property) as unknown; + // Les méthodes doivent rester liées à leur client : détachées du proxy, + // elles perdraient leur `this`. + return typeof value === 'function' ? value.bind(instance) : value; + }, + has(_target, property) { + return property in client(); + }, +}); diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts index fece2e5..e934092 100644 --- a/tests/unit/env.test.ts +++ b/tests/unit/env.test.ts @@ -1,15 +1,23 @@ 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. + * Le module de configuration valide à la **première lecture**, pas à l'import : + * Next.js évalue les modules serveur pendant la construction de l'image, où ni + * la base ni la clé n'existent. + * + * Les valeurs doivent donc rester en place pendant la lecture — les restaurer + * avant de lire ne prouverait rien, sinon que le module n'a rien lu. */ -async function loadEnv(values: Record) { +async function withEnv( + values: Record, + read: (env: typeof import('@/lib/env').env) => T, +): Promise { const previous = { ...process.env }; process.env = { ...previous, ...values } as NodeJS.ProcessEnv; try { vi.resetModules(); - return await import('@/lib/env'); + const { env } = await import('@/lib/env'); + return read(env); } finally { process.env = previous; } @@ -19,31 +27,57 @@ 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', - }); + const values = await withEnv( + { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow', + ENCRYPTION_KEY: VALID_KEY, + APP_URL: 'https://planflow.example', + }, + (env) => ({ database: env.DATABASE_URL, app: env.APP_URL }), + ); - expect(env.DATABASE_URL).toContain('planflow'); - expect(env.APP_URL).toBe('https://planflow.example'); + expect(values.database).toContain('planflow'); + expect(values.app).toBe('https://planflow.example'); + }); + + it('n’exige rien tant qu’aucune valeur n’est lue', async () => { + // C'est ce qui rend l'image constructible sans les secrets de production : + // importer le module ne doit pas suffire à le faire échouer. + await expect( + withEnv({ DATABASE_URL: undefined, ENCRYPTION_KEY: undefined }, () => 'ok'), + ).resolves.toBe('ok'); }); 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'), - }), + withEnv( + { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow', + ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'), + }, + (env) => env.ENCRYPTION_KEY, + ), ).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, - }), + withEnv( + { + DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow', + ENCRYPTION_KEY: VALID_KEY, + }, + (env) => env.DATABASE_URL, + ), + ).rejects.toThrow(/DATABASE_URL/); + }); + + it('refuse une configuration absente, à la lecture', async () => { + await expect( + withEnv( + { DATABASE_URL: undefined, ENCRYPTION_KEY: undefined }, + (env) => env.DATABASE_URL, + ), ).rejects.toThrow(/DATABASE_URL/); }); });