Merge pull request #25 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Rendre l'image constructible sans les secrets de production
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-09 12:13:55 +02:00
commit 006e2db833
4 files changed
+133 -24

No files matched your search

+24
View File
@@ -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
+32 -1
View File
@@ -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);
},
});
+24 -4
View File
@@ -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();
},
});
+53 -19
View File
@@ -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<string, string | undefined>) {
async function withEnv<T>(
values: Record<string, string | undefined>,
read: (env: typeof import('@/lib/env').env) => T,
): Promise<T> {
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/);
});
});