Rendre l'image constructible sans les secrets de production
La construction échouait : `src/lib/env.ts` validait le contrat d'environnement à l'import, et `src/server/db.ts` ouvrait la connexion Prisma de même. Next.js évalue les modules serveur pendant la construction, où ni la base ni la clé n'existent — l'image exigeait donc les secrets de production pour être bâtie, c'est-à-dire de les confier au constructeur. C'était à l'envers : ce sont des valeurs d'exécution. Les deux sont désormais résolus à la première lecture. La garantie ne bouge pas : le premier appel utile arrive bien avant qu'une requête aboutisse, et échoue avec le même message. Éprouvé en construisant sans aucune variable, ce que fait exactement Docker. Les tests décrivaient l'ancien contrat — ils restauraient l'environnement avant de lire, ce qui ne prouvait plus rien une fois la lecture différée. Réécrits pour lire pendant que les valeurs sont posées, avec deux cas de plus : l'import seul n'exige rien, et la lecture d'une configuration absente échoue. Ajout d'un `.dockerignore`, absent jusqu'ici. `.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, et copié dans le contexte il aurait fini dans une couche de l'image, lisible par quiconque la récupère. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
4 files changed
+133
-24
No files matched your search
@@ -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
@@ -51,4 +51,35 @@ function load(): Env {
|
|||||||
return parsed.data;
|
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
@@ -24,8 +24,28 @@ function createClient(): PrismaClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? createClient();
|
/**
|
||||||
|
* Client construit à la **première utilisation**, pas à l'import.
|
||||||
if (env.NODE_ENV !== 'production') {
|
*
|
||||||
globalForPrisma.prisma = prisma;
|
* 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
@@ -1,15 +1,23 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The env module validates at import, so each case needs a fresh module
|
* Le module de configuration valide à la **première lecture**, pas à l'import :
|
||||||
* registry with process.env set beforehand.
|
* 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 };
|
const previous = { ...process.env };
|
||||||
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
return await import('@/lib/env');
|
const { env } = await import('@/lib/env');
|
||||||
|
return read(env);
|
||||||
} finally {
|
} finally {
|
||||||
process.env = previous;
|
process.env = previous;
|
||||||
}
|
}
|
||||||
@@ -19,31 +27,57 @@ const VALID_KEY = Buffer.alloc(32, 7).toString('base64');
|
|||||||
|
|
||||||
describe('configuration d’environnement', () => {
|
describe('configuration d’environnement', () => {
|
||||||
it('accepte une configuration complète', async () => {
|
it('accepte une configuration complète', async () => {
|
||||||
const { env } = await loadEnv({
|
const values = await withEnv(
|
||||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
{
|
||||||
ENCRYPTION_KEY: VALID_KEY,
|
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||||
APP_URL: 'https://planflow.example',
|
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(values.database).toContain('planflow');
|
||||||
expect(env.APP_URL).toBe('https://planflow.example');
|
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 () => {
|
it('refuse une clé de chiffrement qui ne fait pas 32 octets', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadEnv({
|
withEnv(
|
||||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
{
|
||||||
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||||
}),
|
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
||||||
|
},
|
||||||
|
(env) => env.ENCRYPTION_KEY,
|
||||||
|
),
|
||||||
).rejects.toThrow(/ENCRYPTION_KEY/);
|
).rejects.toThrow(/ENCRYPTION_KEY/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuse une URL de base non PostgreSQL', async () => {
|
it('refuse une URL de base non PostgreSQL', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadEnv({
|
withEnv(
|
||||||
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
|
{
|
||||||
ENCRYPTION_KEY: VALID_KEY,
|
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/);
|
).rejects.toThrow(/DATABASE_URL/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user