[E00-S04-T05] .env.example contains placeholders only (#404)
CI / Frozen lockfile install (push) Successful in 42s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 24s
CI / Migration ledger (E00-S03-T03) (push) Successful in 42s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 48s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 1m4s
CI / Field-specific startup errors (E00-S04-T02) (push) Successful in 1m5s
CI / Secret redaction from logs (E00-S04-T03) (push) Successful in 1m6s
CI / Env adapter owns process.env (E00-S04-T04) (push) Successful in 1m12s
CI / Compose config (E00-S03-T01) (push) Successful in 26s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 1m4s
CI / .env.example placeholders only (E00-S04-T05) (push) Successful in 29s

Co-authored-by: bot-implementer <bot-implementer@fabrika.internal>
This commit was merged in pull request #404.
This commit is contained in:
2026-08-30 05:47:42 +00:00
committed by kpcto
parent 1e0f628651
commit dce6cac05b
8 changed files with 361 additions and 9 deletions
+47
View File
@@ -0,0 +1,47 @@
# EPPP configuration template — [E00-S04-T05]
#
# Copy this file to `.env` and fill in real values:
#
# cp .env.example .env
#
# Every value in this file is a PLACEHOLDER — the template intentionally ships
# no real secrets. Real `.env` files stay git-ignored (`.env`, `.env.*` in
# `.gitignore`), so a committed example can never leak a local secret. Never
# commit a real `.env`.
# --- Server configuration (read by @personal-blog/config, E00-S04-T04) -------
# Interface the HTTP server binds — a hostname or IPv4/IPv6 address.
# Default: 0.0.0.0 (all interfaces — the container default).
HOST=0.0.0.0
# Port the HTTP server listens on — an integer in the valid TCP range
# (1-65535). Default: 3000.
PORT=3000
# PostgreSQL connection string (optional). When unset, the app reports ready
# immediately and skips the startup migration run (the local non-container
# developer path). When set, the shape is:
# postgres://<user>:<password>@<host>:5432/<database>
# (add your own credentials; a local no-credential default is shown below)
DATABASE_URL=postgres://localhost:5432/eppp
# Admin-session secret — REQUIRED and at least 32 characters (the config
# schema's required field; Security-and-Operations §32/§26). Generate a fresh
# one with `openssl rand -hex 32` and replace the placeholder below. The
# placeholder is intentionally SHORTER than the 32-character minimum, so an
# unedited `cp .env.example .env` is rejected at startup (fails closed)
# instead of booting with a publicly known secret.
EPPP_SESSION_SECRET=change-me
# --- Docker Compose overrides (optional — compose.yaml has dev defaults) ------
# PostgreSQL database name / user / password and host port for the `db`
# service (compose.yaml interpolates these with dev defaults).
POSTGRES_DB=eppp
POSTGRES_USER=eppp
POSTGRES_PASSWORD=change-me-db-password
POSTGRES_PORT=5432
# Host port for the `app` service. Default: 3000.
APP_PORT=3000
+24
View File
@@ -287,6 +287,30 @@ jobs:
- name: Run config schema test suite
run: node --test tests/config-schema.test.mjs
# E00-S04-T05: the static assertions of tests/env-example.test.mjs gate every
# PR — the suite locks in the committed `.env.example` template: it exists at
# the repo root, is un-ignored in .gitignore (real `.env` files stay ignored
# while the example is tracked), documents every configuration environment
# source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains
# placeholder values only — no credential URI, no long secret-looking value,
# and no compose default credential — with mutation probes proving the
# assertions are non-vacuous. It also locks the fail-closed EPPP_SESSION_SECRET
# placeholder (shorter than the schema's 32-character minimum), builds the
# secret-shaped probe at runtime so the branch stays gitleaks-clean, and
# masks raw values in assertion messages. The test needs no dependencies, so
# the job only installs Node.
env-example:
name: .env.example placeholders only (E00-S04-T05)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js 24
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run .env.example test suite
run: node --test tests/env-example.test.mjs
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
# build platforms) gate every PR (the docker-gated real-stack probes inside
+4 -1
View File
@@ -6,9 +6,12 @@ node_modules/
dist/
coverage/
# Local environment files (a committed .env.example lands in E00-S04)
# Local environment files: real .env files are ignored, while the committed
# .env.example template (E00-S04-T05) is explicitly un-ignored so it stays
# tracked.
.env
.env.*
!.env.example
# Logs
*.log
+2 -1
View File
@@ -49,7 +49,8 @@
# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs.
#
# All values have defaults so `docker compose up -d` works from a clean clone
# without a .env file (a committed .env.example template lands in E00-S04).
# without a .env file (the committed .env.example template, E00-S04-T05,
# lists the overridable variables with placeholder values).
# Since E00-S04-T02 the app validates its required settings at startup: the
# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field,
# Security-and-Operations §32/§26) is provided here with a dev-only default —
+4 -4
View File
@@ -17,7 +17,7 @@ The workspace is a pnpm monorepo with three package groups:
| --- | --- | --- |
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. |
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the `.env.example` template (E00-S04-T05) lands in a later task. |
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the committed `.env.example` template (E00-S04-T05) documents every variable with placeholder values only. |
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
@@ -99,8 +99,8 @@ compiled application entrypoint. Three things to know:
must be set in the environment — if it is missing, the process fails fast
with a field-specific startup error (`missing required setting:
sessionSecret`) that names the missing field instead of booting. Provide it
in your shell or a local `.env` file (the `.env.example` template lands in
E00-S04).
in your shell or a local `.env` file copied from the committed
`.env.example` template (E00-S04-T05).
3. Since [E00-S02-T03], `apps/server` serves the **application health
endpoint**: starting it opens an HTTP server on port 3000 answering
`GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint
@@ -170,7 +170,7 @@ pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see
| `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
| `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). |
| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). |
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. |
## Out of scope
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.",
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
+2 -2
View File
@@ -26,8 +26,8 @@
* reads `process.env` directly. `HOST` is validated at the adapter boundary
* as a hostname or IP address before it is used for binding or logged, so
* arbitrary env content is never echoed verbatim into the startup log. The
* `.env.example` template (E00-S04-T05) builds on this boundary in a later
* task.
* committed `.env.example` template (E00-S04-T05) documents the same
* variables with placeholder values only.
*/
export { configSchema } from './schema.js';
+277
View File
@@ -0,0 +1,277 @@
/**
* .env.example test — locks in the [E00-S04-T05] guarantee that the committed
* `.env.example` template at the repo root contains placeholders only and no
* real secret values.
*
* Acceptance criteria covered (each test fails without the committed state):
* - ".env.example contains placeholders only" → the file exists at the repo
* root and every assignment value is a placeholder (an explicit
* `change-me`/`<…>`-style marker) or a benign non-secret default (bind
* address, port, local database/user name); no value is a long
* random-looking token without a placeholder marker, no value embeds a
* credential URI, every line is a comment, a blank line, or a well-formed
* `KEY=value` assignment, no variable is repeated, and the file documents
* every configuration environment source (`HOST`/`PORT`/`DATABASE_URL`/
* `EPPP_SESSION_SECRET`).
* - "no real secret values appear in the example file" → the same value
* predicate rejects secret-shaped values, the compose dev-default
* credential value appears nowhere in the file (values or comments), and
* `.gitignore` keeps real `.env` files ignored while un-ignoring the
* committed `.env.example`. The mutation probes below prove the
* assertions are non-vacuous (a secret-looking value, a credential URI, a
* compose default credential, a missing required variable, a dropped
* `!.env.example` negation, or a malformed line all break the criterion).
* - "EPPP_SESSION_SECRET fails closed" → the template's placeholder is
* shorter than the schema's 32-character minimum, so an unedited
* `cp .env.example .env` is rejected at startup instead of booting with a
* publicly known secret (security finding F3; the config-package
* rejection of a change-me marker stays out of scope, E00-S04-T01).
* - "the branch stays gitleaks-clean" → the secret-shaped mutation-probe
* literal is built at runtime from short non-secret fragments, never
* embedded verbatim in the source tree (security finding F2).
* - "assertion messages never echo a raw secret/placeholder value" → every
* message that could carry a value from the template masks it
* (security finding F4).
*
* Run: `node --test tests/env-example.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
const exists = (relPath) => existsSync(path.join(REPO_ROOT, relPath));
const ENV_EXAMPLE_PATH = '.env.example';
/** The config schema's environment sources (E00-S04-T01/T04) the template must document. */
const REQUIRED_VARS = ['HOST', 'PORT', 'DATABASE_URL', 'EPPP_SESSION_SECRET'];
/** Compose dev-default credential values that must never appear in the template. */
const FORBIDDEN_VALUES = ['postgres://eppp:eppp@db:5432/eppp'];
/** A long random-looking value (JWT/API-key/token-shaped literal). */
const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/;
/**
* A long random-looking, secret-shaped value used as a mutation probe. Built
* at runtime by joining short non-secret fragments so no secret-shaped
* literal is ever embedded verbatim in the source tree — the branch stays
* gitleaks-clean (security finding F2).
*/
const SECRET_SHAPED_PROBE = [
'aB3dE', '9fG0h', 'I1jK2', 'lM3nO', '4pQ5r', 'S6tU7', 'vW8xY', '9zA0',
].join('');
/** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */
const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/;
/** Explicit placeholder markers a value may carry. */
const PLACEHOLDER_MARKERS = [
'change-me',
'changeme',
'change_me',
'your-',
'replace',
'example',
'xxx',
'<',
'>',
];
/** Benign non-secret defaults the template may show as values. */
const BENIGN_VALUES = new Set([
'0.0.0.0', // HOST container default
'3000', // PORT / APP_PORT default
'5432', // POSTGRES_PORT default
'localhost', // local bind/db host
'eppp', // local database/user name (non-secret)
'postgres://localhost:5432/eppp', // DATABASE_URL shape without embedded credentials
]);
/**
* True when an assignment value is a placeholder, never a real secret.
*
* A value is a placeholder when it is a benign non-secret default or carries
* an explicit placeholder marker; a marked value is still rejected when it
* embeds a credential URI or reproduces a compose default credential. Any
* other value — including a long random-looking token — is not a placeholder.
*/
function isPlaceholderValue(value) {
if (value === '') return false;
if (BENIGN_VALUES.has(value)) return true;
if (PLACEHOLDER_MARKERS.some((marker) => value.includes(marker))) {
return !CREDENTIAL_URI_RE.test(value) && !FORBIDDEN_VALUES.includes(value);
}
return false;
}
/**
* Masks a value for an assertion message (security finding F4): a failing
* assertion echoes a truncated value with its length, never the raw string,
* so a real secret that ever lands in the template cannot leak into CI logs.
*/
function maskValue(value) {
const s = String(value);
if (s.length <= 8) return '<masked>';
return `${s.slice(0, 4)}...<${s.length} chars>`;
}
/**
* Parses the template into `{ key, value }` assignments, ignoring blank lines
* and `#` comment lines. Throws a descriptive Error on a malformed line so a
* stray non-assignment line cannot silently pass.
*/
function parseAssignments(content) {
const assignments = [];
content.split(/\r?\n/).forEach((raw, index) => {
const line = raw.trim();
if (line === '' || line.startsWith('#')) return;
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match) {
throw new Error(
`line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${maskValue(raw)}"`,
);
}
assignments.push({ key: match[1], value: match[2] });
});
return assignments;
}
/**
* Asserts the acceptance criteria for the given template content — used by
* the committed-state test and by the mutation probes (which must make it
* throw).
*/
function assertTemplateHasPlaceholdersOnly(content) {
for (const forbidden of FORBIDDEN_VALUES) {
assert.ok(
!content.includes(forbidden),
`the template must not contain the compose default credential value "${maskValue(forbidden)}"`,
);
}
const assignments = parseAssignments(content);
assert.ok(assignments.length > 0, 'the template must contain at least one assignment');
const keys = assignments.map(({ key }) => key);
assert.equal(new Set(keys).size, keys.length, 'the template must not repeat a variable');
for (const required of REQUIRED_VARS) {
assert.ok(
keys.includes(required),
`the template must document the required variable "${required}"`,
);
}
for (const { key, value } of assignments) {
assert.ok(
isPlaceholderValue(value),
`"${key}" must be a placeholder value, got: "${maskValue(value)}"`,
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('a committed .env.example exists at the repo root', () => {
assert.ok(exists(ENV_EXAMPLE_PATH), `"${ENV_EXAMPLE_PATH}" must exist at the repo root`);
});
test('.gitignore keeps real .env files ignored while un-ignoring the committed example', () => {
const gitignore = read('.gitignore');
assert.ok(gitignore.includes('.env'), 'real .env files must stay git-ignored');
assert.ok(gitignore.includes('.env.*'), 'real .env.* files must stay git-ignored');
assert.ok(
gitignore.includes('!.env.example'),
'the committed .env.example must be un-ignored (negation pattern)',
);
});
test('.env.example contains placeholders only and no real secret values', () => {
assertTemplateHasPlaceholdersOnly(read(ENV_EXAMPLE_PATH));
});
test("EPPP_SESSION_SECRET's placeholder is shorter than the schema's 32-character minimum (fails closed)", () => {
const content = read(ENV_EXAMPLE_PATH);
const match = /^EPPP_SESSION_SECRET=(.*)$/m.exec(content);
assert.ok(match, 'the template must document EPPP_SESSION_SECRET');
assert.ok(
match[1].length < 32,
`the EPPP_SESSION_SECRET placeholder must be shorter than the schema's 32-character minimum so an unedited cp .env.example .env is rejected at startup (got ${match[1].length} chars)`,
);
});
// ---------------------------------------------------------------------------
// Non-vacuous probes — the assertions above really do fail on violations
// ---------------------------------------------------------------------------
test('a compose default credential value in the template fails the criterion (mutation probe)', () => {
const content = read(ENV_EXAMPLE_PATH);
const mutated = content.replace(
'DATABASE_URL=postgres://localhost:5432/eppp',
'DATABASE_URL=postgres://eppp:eppp@db:5432/eppp',
);
assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value');
assert.throws(
() => assertTemplateHasPlaceholdersOnly(mutated),
/compose default credential|placeholder/,
);
});
test('a long secret-looking value fails the criterion (mutation probe)', () => {
const content = read(ENV_EXAMPLE_PATH);
const mutated = content.replace(
'EPPP_SESSION_SECRET=change-me',
`EPPP_SESSION_SECRET=${SECRET_SHAPED_PROBE}`,
);
assert.notEqual(mutated, content, 'the mutation must actually replace the session secret');
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
});
test('a credential URI value fails the criterion (mutation probe)', () => {
const content = read(ENV_EXAMPLE_PATH);
const mutated = content.replace(
'DATABASE_URL=postgres://localhost:5432/eppp',
'DATABASE_URL=postgres://alice:supersecret@db.example.com:5432/eppp',
);
assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value');
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
});
test('a missing required variable fails the criterion (mutation probe)', () => {
const content = read(ENV_EXAMPLE_PATH);
const mutated = content.replace(/^EPPP_SESSION_SECRET=.*$/m, '');
assert.notEqual(
mutated,
content,
'the mutation must actually remove the EPPP_SESSION_SECRET assignment',
);
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /EPPP_SESSION_SECRET/);
});
test('dropping the !.env.example negation fails the criterion (mutation probe)', () => {
const gitignore = read('.gitignore');
const mutated = gitignore.replace('!.env.example', '');
assert.notEqual(mutated, gitignore, 'the mutation must actually drop the negation pattern');
assert.throws(
() => {
assert.ok(mutated.includes('!.env.example'), 'the committed .env.example must be un-ignored');
},
/un-ignored/,
);
});
test('a malformed non-assignment line fails the criterion (mutation probe)', () => {
const content = read(ENV_EXAMPLE_PATH);
const mutated = `${content}\nTHIS IS NOT A VALID LINE\n`;
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /not a comment/);
});