diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5774a38..3d51984 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -167,6 +167,33 @@ jobs: - name: Run app readiness test suite run: node --test tests/app-readiness.test.mjs + # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate + # every PR — the suite locks in the TypeBox/Ajv configuration schema + # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + + # ajv@8.20.0) with mutation probes, and the deterministic probe executes + # the issue's test plan ("validate a full config against the TypeBox/Ajv + # schema") against the committed schema through Ajv. The job installs the + # frozen workspace and builds the config package because the probe also + # exercises the compiled package boundary (@personal-blog/config) exactly + # as the later configuration adapter will consume it. + config-schema: + name: TypeBox/Ajv config schema (E00-S04-T01) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config package (the probe exercises the compiled package boundary) + run: pnpm --filter @personal-blog/config build + - name: Run config schema test suite + run: node --test tests/config-schema.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 diff --git a/.gitignore b/.gitignore index 5352da1..e0dcf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,9 @@ coverage/ .lock-probe-*.mjs .diagnostic-probe-*.mjs +# Transient host-side probe file written by the config-schema test suite into +# the package (removed in its finally block) +.config-schema-probe-*.mjs + # OS / editor .DS_Store diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index fd75385..f2050e5 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -57,11 +57,12 @@ RUN corepack enable # invalidate the dependency layer, then install against the committed lockfile # (the same `--frozen-lockfile` path CI and developers use). Every workspace # package manifest is copied so the in-image workspace matches the lockfile -# importers exactly (apps/server, packages/core, packages/database-postgres, -# extensions/example). +# importers exactly (apps/server, packages/core, packages/config, +# packages/database-postgres, extensions/example). COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ COPY apps/server/package.json apps/server/package.json COPY packages/core/package.json packages/core/package.json +COPY packages/config/package.json packages/config/package.json COPY packages/database-postgres/package.json packages/database-postgres/package.json COPY extensions/example/package.json extensions/example/package.json RUN pnpm install --frozen-lockfile diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 33a5483..1956391 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,6 +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); 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 environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret redaction (E00-S04-T03) land in later tasks. | | `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. | @@ -76,8 +77,8 @@ pnpm typecheck `pnpm build` runs `tsc -p tsconfig.json` in each package in topological order and emits `dist/` (JavaScript + type declarations + source maps) per package. Expected result: `apps/server/dist/`, `packages/core/dist/`, -`packages/database-postgres/dist/` and `extensions/example/dist/` are -produced, all packages report `Done`, exit 0. +`packages/config/dist/`, `packages/database-postgres/dist/` and +`extensions/example/dist/` are produced, all packages report `Done`, exit 0. `dist/` is git-ignored; rebuild whenever you change `src/`. ## Run @@ -127,8 +128,8 @@ This is also the suite that enforces the dependency-boundary rule. git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog corepack enable pnpm install --frozen-lockfile # exit 0, lockfile untouched -pnpm build # 4/4 packages emit dist/, exit 0 -pnpm typecheck # 4/4 packages pass --noEmit, exit 0 +pnpm build # 5/5 packages emit dist/, exit 0 +pnpm typecheck # 5/5 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up ``` diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..700a3da --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,23 @@ +{ + "name": "@personal-blog/config", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "0.34.52", + "ajv": "8.20.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..1bc4602 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,14 @@ +/** + * @personal-blog/config — EPPP configuration service. + * + * [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the + * configuration schema (`configSchema`, defined with TypeBox) and the Ajv + * schema-validation entry point (`validateConfig`). The environment adapter + * (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret + * redaction (E00-S04-T03) build on this boundary in later tasks. + */ + +export { configSchema } from './schema.js'; +export type { Config } from './schema.js'; +export { validateConfig } from './validate.js'; +export type { ConfigValidationResult } from './validate.js'; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts new file mode 100644 index 0000000..e07b6e4 --- /dev/null +++ b/packages/config/src/schema.ts @@ -0,0 +1,52 @@ +/** + * EPPP configuration schema — [E00-S04-T01] TypeBox/Ajv schema. + * + * The single source of truth for the **validated configuration fields** of + * the EPPP server (the `@personal-blog/config` package). The schema is + * defined with TypeBox (`@sinclair/typebox`, golden-tuple pin 0.34.52 — + * Technology-Stack §5.2/§7) and validated with Ajv (`ajv`, golden-tuple pin + * 8.20.0) via `validateConfig` (see `validate.ts`). E00-S04-T02 + * (field-specific startup errors), T03 (secret redaction), T04 (the + * `process.env` adapter) and T05 (`.env.example` placeholders) build on this + * schema; this module only defines it. + * + * The validated config fields: + * + * - `host` — the interface the HTTP server binds. Default `0.0.0.0` (the + * committed server binds all interfaces today, E00-S02-T03). Environment + * source: `HOST`. + * - `port` — the port the HTTP server listens on. Integer in the valid TCP + * port range (1–65535), default `3000` (the container default, matching + * the Dockerfile `EXPOSE 3000` and the compose `:3000` container port; + * `PORT` is read today, E00-S02-T03). + * - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool` + * `connectionString`, E00-S03-T02). Optional: when absent the app has no + * startup migration run to wait for and reports ready immediately (the + * local non-container developer path, E00-S01-T06/E00-S03-T06). Must be + * non-empty when present. Environment source: `DATABASE_URL`. + * - `sessionSecret` — the admin-session secret (`EPPP_SESSION_SECRET`, the + * required secret per Security-and-Operations §32/§26). **Required** and at + * least 32 characters: it is the story's secret field (E00-S04-T03 redacts + * it from logs) and the schema's required field (E00-S04-T02 reports a + * missing required setting). No default — a secret must never be invented + * by the schema. + * + * The object is closed (`additionalProperties: false`) so a typo'd or + * unexpected setting is rejected loudly instead of silently ignored. + */ + +import { Type, type Static } from '@sinclair/typebox'; + +/** The EPPP configuration schema — validates the parsed configuration object. */ +export const configSchema = Type.Object( + { + host: Type.Optional(Type.String({ default: '0.0.0.0' })), + port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535, default: 3000 })), + databaseUrl: Type.Optional(Type.String({ minLength: 1 })), + sessionSecret: Type.String({ minLength: 32 }), + }, + { additionalProperties: false }, +); + +/** The validated configuration type — `Static` of `configSchema`. */ +export type Config = Static; diff --git a/packages/config/src/validate.ts b/packages/config/src/validate.ts new file mode 100644 index 0000000..81435d2 --- /dev/null +++ b/packages/config/src/validate.ts @@ -0,0 +1,49 @@ +/** + * EPPP configuration validation — [E00-S04-T01] TypeBox/Ajv schema. + * + * Compiles the TypeBox `configSchema` with Ajv (the golden-tuple validator, + * Technology-Stack §5.2/§7) and exposes `validateConfig`, the generic + * schema-validation entry point: given an unknown value it reports whether + * the value is a valid configuration and the Ajv error messages otherwise. + * + * This is deliberately NOT the E00-S04-T02 field-specific startup error: + * `validateConfig` returns the raw schema-validation outcome (valid or not, + * with the Ajv messages) and performs no startup wiring — the adapter + * (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it + * in later tasks. + */ + +import { Ajv } from 'ajv'; + +import { configSchema } from './schema.js'; + +/** Ajv instance for the config schema — `allErrors` reports every violation. */ +const ajv = new Ajv({ allErrors: true }); + +/** The compiled validator — TypeBox schemas are JSON Schema, so Ajv compiles them directly. */ +const validateConfigValue = ajv.compile(configSchema); + +/** The outcome of validating a value against the config schema. */ +export interface ConfigValidationResult { + /** True when the value is a valid configuration. */ + valid: boolean; + /** Ajv error messages, empty when `valid` is true. */ + errors: string[]; +} + +/** + * Validates an unknown value against the config schema. + * + * @param value - the value to validate (typically the parsed config object) + * @returns `{ valid: true, errors: [] }` for a valid configuration, or + * `{ valid: false, errors }` with the Ajv messages naming each violation + * (e.g. `"must have required property 'sessionSecret'"`). + */ +export function validateConfig(value: unknown): ConfigValidationResult { + const valid = validateConfigValue(value); + if (valid) { + return { valid: true, errors: [] }; + } + const errors = (validateConfigValue.errors ?? []).map((error) => error.message ?? 'invalid'); + return { valid: false, errors }; +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 406e0c9..3669a69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,15 @@ importers: extensions/example: {} + packages/config: + dependencies: + '@sinclair/typebox': + specifier: 0.34.52 + version: 0.34.52 + ajv: + specifier: 8.20.0 + version: 8.20.0 + packages/core: {} packages/database-postgres: @@ -41,12 +50,27 @@ importers: packages: + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@types/pg@8.21.0': resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + kysely@0.29.4: resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} engines: {node: '>=22.0.0'} @@ -101,6 +125,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -119,6 +147,8 @@ packages: snapshots: + '@sinclair/typebox@0.34.52': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -129,6 +159,19 @@ snapshots: pg-protocol: 1.16.0 pg-types: 2.2.0 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.6: {} + + json-schema-traverse@1.0.0: {} + kysely@0.29.4: {} pg-cloudflare@1.4.0: @@ -176,6 +219,8 @@ snapshots: dependencies: xtend: 4.0.2 + require-from-string@2.0.2: {} + split2@4.2.0: {} typescript@6.0.3: {} diff --git a/tests/config-schema.test.mjs b/tests/config-schema.test.mjs new file mode 100644 index 0000000..69c8b56 --- /dev/null +++ b/tests/config-schema.test.mjs @@ -0,0 +1,415 @@ +/** + * Config schema test — locks in the [E00-S04-T01] TypeBox/Ajv configuration + * schema for the workspace. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "configuration schema is defined with TypeBox/Ajv" → the new + * `packages/config` package (`@personal-blog/config`) pins the + * golden-tuple runtime deps exactly (`@sinclair/typebox@0.34.52`, + * `ajv@8.20.0` — Technology-Stack §5.2/§7), its `src/schema.ts` defines + * `configSchema` with TypeBox (`Type.Object`), and its `src/validate.ts` + * compiles that schema with Ajv (`new Ajv({ allErrors: true })` + + * `.compile(configSchema)`) and exports `validateConfig`; the package + * boundary re-exports both. Mutation probes prove the assertions are + * non-vacuous (dropping a field, relaxing a constraint, replacing + * TypeBox/Ajv with a hand-rolled object all fail). + * - "schema covers the validated config fields" → the schema defines the + * four validated config fields with their constraints: `host` (string, + * default `0.0.0.0`), `port` (integer 1–65535, default `3000`), + * `databaseUrl` (optional non-empty string — the local non-container path + * has no database), `sessionSecret` (required, ≥ 32 chars — the story's + * secret field, EPPP_SESSION_SECRET, Security-and-Operations §32/§26), + * and the object is closed (`additionalProperties: false`). + * - the issue's test plan — "validate a full config against the + * TypeBox/Ajv schema" → the deterministic probe imports the committed + * `src/schema.ts` (Node type stripping, no build step) and validates a + * full config through Ajv, plus the negative cases (missing required + * field naming `sessionSecret`, secret too short, unknown property, port + * out of range / not an integer, empty `databaseUrl`); when the package + * is built (CI builds it first) the probe additionally exercises the + * compiled `validateConfig` boundary exactly as the later configuration + * adapter will consume it. + * + * Run: `node --test tests/config-schema.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +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'); + +/** The committed config package under test. */ +const CONFIG_DIR = 'packages/config'; +const MANIFEST_PATH = `${CONFIG_DIR}/package.json`; +const SCHEMA_SRC = `${CONFIG_DIR}/src/schema.ts`; +const VALIDATE_SRC = `${CONFIG_DIR}/src/validate.ts`; +const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the config-schema criterion on every PR. */ +const CI_JOB = 'config-schema'; + +/** Golden-tuple pins for the configuration schema (Technology-Stack §5.2/§7). */ +const GOLDEN_TYPEBOX = '0.34.52'; +const GOLDEN_AJV = '8.20.0'; + +// --------------------------------------------------------------------------- +// Static assertions on the committed config package +// --------------------------------------------------------------------------- + +/** + * Asserts the schema is defined with TypeBox and covers the validated config + * fields with their documented constraints. Fails fast on any deviation; the + * mutation probes below prove the assertions are non-vacuous. + */ +function assertSchemaShape(src) { + // Defined with TypeBox: the schema object is built by Type.Object from the + // @sinclair/typebox `Type` factory (never a hand-rolled plain object). + assert.match( + src, + /import \{ Type, type Static \} from '@sinclair\/typebox'/, + 'the schema module must import Type (and the Static type) from @sinclair/typebox', + ); + assert.match( + src, + /export const configSchema = Type\.Object\(/, + 'the schema must be defined with TypeBox (Type.Object) — not a hand-rolled object', + ); + + // The validated config fields, each with its documented constraint. + assert.match( + src, + /host: Type\.Optional\(Type\.String\(\{ default: '0\.0\.0\.0' \}\)\)/, + 'the schema must cover the host field (optional string, default 0.0.0.0 — the bind address)', + ); + assert.match( + src, + /port: Type\.Optional\(Type\.Integer\(\{ minimum: 1, maximum: 65535, default: 3000 \}\)\)/, + 'the schema must cover the port field (optional integer 1-65535, default 3000)', + ); + assert.match( + src, + /databaseUrl: Type\.Optional\(Type\.String\(\{ minLength: 1 \}\)\)/, + 'the schema must cover the databaseUrl field (optional non-empty string)', + ); + assert.match( + src, + /sessionSecret: Type\.String\(\{ minLength: 32 \}\)/, + 'the schema must cover the sessionSecret field (required string, at least 32 chars — never Type.Optional)', + ); + assert.match( + src, + /additionalProperties: false/, + 'the schema must close the object (additionalProperties: false) so unexpected settings are rejected', + ); +} + +/** Asserts the validator compiles the schema with Ajv and exports validateConfig. */ +function assertValidateShape(src) { + assert.match( + src, + /import \{ Ajv \} from 'ajv'/, + 'the validator module must import Ajv (the golden-tuple validator)', + ); + assert.match( + src, + /new Ajv\(\{ allErrors: true \}\)/, + 'the validator must create the Ajv instance with allErrors (report every violation)', + ); + assert.match( + src, + /\.compile\(configSchema\)/, + 'the validator must compile the TypeBox configSchema with Ajv', + ); + assert.match( + src, + /export function validateConfig/, + 'the validator module must export the validateConfig entry point', + ); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the config package exists, pins the golden-tuple runtime deps exactly, and builds with tsc', () => { + const manifest = JSON.parse(read(MANIFEST_PATH)); + assert.equal(manifest.name, '@personal-blog/config'); + assert.equal( + manifest.dependencies?.['@sinclair/typebox'], + GOLDEN_TYPEBOX, + `@sinclair/typebox must be pinned exactly to the golden tuple (${GOLDEN_TYPEBOX})`, + ); + assert.match( + manifest.dependencies?.['@sinclair/typebox'], + /^\d+\.\d+\.\d+$/, + 'the @sinclair/typebox pin must be exact (no ^ / ~ / range)', + ); + assert.equal( + manifest.dependencies?.ajv, + GOLDEN_AJV, + `ajv must be pinned exactly to the golden tuple (${GOLDEN_AJV})`, + ); + assert.match( + manifest.dependencies?.ajv, + /^\d+\.\d+\.\d+$/, + 'the ajv pin must be exact (no ^ / ~ / range)', + ); + // The CI job and the local build path compile the package with tsc. + assert.equal(manifest.scripts?.build, 'tsc -p tsconfig.json'); + assert.equal(manifest.scripts?.typecheck, 'tsc -p tsconfig.json --noEmit'); +}); + +test('the configuration schema is defined with TypeBox and covers the validated config fields', () => { + assert.ok(existsSync(path.join(REPO_ROOT, SCHEMA_SRC)), `committed ${SCHEMA_SRC} must exist`); + assertSchemaShape(read(SCHEMA_SRC)); +}); + +test('the schema is validated with Ajv: validate.ts compiles configSchema and exports validateConfig', () => { + assert.ok(existsSync(path.join(REPO_ROOT, VALIDATE_SRC)), `committed ${VALIDATE_SRC} must exist`); + assertValidateShape(read(VALIDATE_SRC)); +}); + +test('the package boundary re-exports the schema and the validator', () => { + const src = read(INDEX_SRC); + assert.match(src, /export \{ configSchema \} from '\.\/schema\.js'/, 'the boundary must re-export configSchema'); + assert.match(src, /export type \{ Config \} from '\.\/schema\.js'/, 'the boundary must re-export the Config type'); + assert.match(src, /export \{ validateConfig \} from '\.\/validate\.js'/, 'the boundary must re-export validateConfig'); + assert.match(src, /export type \{ ConfigValidationResult \} from '\.\/validate\.js'/, 'the boundary must re-export the ConfigValidationResult type'); +}); + +test('the config-schema criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/config-schema.test.mjs`), + `CI must run the config-schema suite (job "${CI_JOB}") on every PR`, + ); + assert.ok( + workflow.includes(`pnpm --filter @personal-blog/config build`), + 'the CI job must build the config package (the probe exercises the compiled boundary)', + ); +}); + +// --------------------------------------------------------------------------- +// Mutation probes — the static assertions are non-vacuous +// --------------------------------------------------------------------------- + +test('removing a validated config field from the schema fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace( + /sessionSecret: Type\.String\(\{ minLength: 32 \}\),\n/, + '', + ); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually remove the sessionSecret field'); + assert.throws(() => assertSchemaShape(mutated), /sessionSecret/); +}); + +test('making the secret optional fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace( + 'sessionSecret: Type.String({ minLength: 32 })', + 'sessionSecret: Type.Optional(Type.String({ minLength: 32 }))', + ); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually make sessionSecret optional'); + assert.throws(() => assertSchemaShape(mutated), /never Type\.Optional/); +}); + +test('relaxing the secret length fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace('minLength: 32', 'minLength: 8'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually relax the minLength'); + assert.throws(() => assertSchemaShape(mutated), /at least 32 chars/); +}); + +test('opening the object fails the coverage assertion (mutation probe)', () => { + // Replace every occurrence (the docstring mentions the keyword too) so the + // committed schema code itself is the mutation target. + const mutated = read(SCHEMA_SRC).replace(/additionalProperties: false/g, 'additionalProperties: true'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually open the object'); + assert.throws(() => assertSchemaShape(mutated), /additionalProperties: false/); +}); + +test('replacing TypeBox with a hand-rolled object fails the TypeBox assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC) + .replace("import { Type, type Static } from '@sinclair/typebox';", '// no TypeBox') + .replace('export const configSchema = Type.Object(', 'export const configSchema = {'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually drop TypeBox'); + assert.throws(() => assertSchemaShape(mutated), /must import Type/); +}); + +test('dropping the Ajv compile fails the validator assertion (mutation probe)', () => { + const mutated = read(VALIDATE_SRC) + .replace("import { Ajv } from 'ajv';", '// no Ajv') + .replace('const validateConfigValue = ajv.compile(configSchema);', 'const validateConfigValue = () => true;'); + assert.notEqual(mutated, read(VALIDATE_SRC), 'the mutation must actually drop the Ajv compile'); + assert.throws(() => assertValidateShape(mutated), /Ajv|compile/); +}); + +// --------------------------------------------------------------------------- +// Deterministic behavioral probe — the issue's test plan: validate a full +// config against the TypeBox/Ajv schema +// --------------------------------------------------------------------------- + +/** + * How the current Node executes TypeScript sources: `default` (>= 23.6, type + * stripping on by default), `strip-types-flag` (>= 22.6 via + * `--experimental-strip-types`) or `null` (cannot run .ts at all). The + * workspace pins engines.node to 24.x, where type stripping is stable. + */ +function tsExecMode() { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major > 23 || (major === 23 && minor >= 6)) return 'default'; + if (major === 22 && minor >= 6) return 'strip-types-flag'; + return null; +} + +const TS_STRIPPING = tsExecMode() !== null; + +/** + * The probe source: imports the committed `src/schema.ts` (type-stripped, + * no build step) and validates a full config plus the negative cases through + * Ajv — the issue's test plan. When the package is built (`dist/` present, + * as in the CI job), it additionally exercises the compiled + * `validateConfig` boundary exactly as the later configuration adapter will. + * Written to a temp file inside `packages/config/` so `@sinclair/typebox` and + * `ajv` resolve through the package's own dependency links, then removed. + */ +const PROBE_SOURCE = ` +import { configSchema } from './src/schema.ts'; +import { Ajv } from 'ajv'; +import { existsSync } from 'node:fs'; + +const validate = new Ajv({ allErrors: true }).compile(configSchema); +const errorsOf = (value) => { + validate(value); + return (validate.errors ?? []).map((error) => error.message ?? 'invalid'); +}; + +const FULL = { + host: '0.0.0.0', + port: 3000, + databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', + sessionSecret: 's'.repeat(32), +}; + +const result = { + // The issue's test plan: "validate a full config against the TypeBox/Ajv + // schema" — a full config is valid. + fullValid: validate(FULL), + // host/port/databaseUrl are optional (port and host carry defaults; the + // local non-container path has no database), so a config with only the + // required secret is valid too. + defaultsValid: validate({ sessionSecret: 's'.repeat(32) }), + // The required field is enforced and the error names it (E00-S04-T02 will + // format these as field-specific startup errors). + missingSecretErrors: errorsOf({ host: '0.0.0.0', port: 3000 }), + shortSecretErrors: errorsOf({ ...FULL, sessionSecret: 'short' }), + // The object is closed: an unexpected setting is rejected loudly. + unknownPropertyErrors: errorsOf({ ...FULL, extra: true }), + // Port is an integer in 1-65535. + portTooHighErrors: errorsOf({ ...FULL, port: 65536 }), + portNotIntegerErrors: errorsOf({ ...FULL, port: '3000' }), + // databaseUrl must be non-empty when present. + emptyDatabaseUrlErrors: errorsOf({ ...FULL, databaseUrl: '' }), +}; + +// Compiled boundary (built by the CI job): the committed validateConfig. +if (existsSync('./dist/index.js')) { + const { validateConfig } = await import('./dist/index.js'); + result.boundaryFullValid = validateConfig(FULL).valid; + result.boundaryMissingSecretErrors = validateConfig({ host: '0.0.0.0', port: 3000 }).errors; + result.boundaryUnknownPropertyErrors = validateConfig({ ...FULL, extra: true }).errors; +} + +console.log('CONFIG_SCHEMA_PROBE_RESULT ' + JSON.stringify(result)); +`; + +test('a full config validates against the committed TypeBox/Ajv schema, and violations are rejected (deterministic probe)', { skip: !TS_STRIPPING }, () => { + // The issue's test plan: "validate a full config against the TypeBox/Ajv + // schema". The probe runs the committed schema.ts through Ajv (Node type + // stripping, no build step) from inside packages/config so the golden-tuple + // deps resolve through the package's own links. + const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-schema-probe-${process.pid}.mjs`); + const args = + tsExecMode() === 'strip-types-flag' + ? ['--experimental-strip-types', path.basename(probeFile)] + : [path.basename(probeFile)]; + try { + writeFileSync(probeFile, PROBE_SOURCE); + const run = spawnSync(process.execPath, args, { + cwd: path.join(REPO_ROOT, CONFIG_DIR), + encoding: 'utf8', + timeout: 60_000, + }); + assert.equal( + run.status, + 0, + `the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`, + ); + const match = run.stdout.match(/CONFIG_SCHEMA_PROBE_RESULT (\{.*\})/); + assert.ok(match, `the probe must print CONFIG_SCHEMA_PROBE_RESULT:\n${run.stdout.trim()}`); + const result = JSON.parse(match[1]); + + // The issue's test plan: a full config validates. + assert.equal(result.fullValid, true, 'a full config must validate against the TypeBox/Ajv schema'); + assert.equal(result.defaultsValid, true, 'host/port/databaseUrl are optional (defaults; no-database path)'); + + // The required field is enforced and the error names it. + assert.ok( + result.missingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)), + `missing sessionSecret must be rejected naming the field (got: ${JSON.stringify(result.missingSecretErrors)})`, + ); + assert.ok( + result.shortSecretErrors.some((msg) => /fewer than 32/.test(msg)), + `a short sessionSecret must be rejected (got: ${JSON.stringify(result.shortSecretErrors)})`, + ); + + // The object is closed and the numeric/string constraints hold. + assert.ok( + result.unknownPropertyErrors.some((msg) => /additional properties/.test(msg)), + `an unknown property must be rejected (got: ${JSON.stringify(result.unknownPropertyErrors)})`, + ); + assert.ok( + result.portTooHighErrors.some((msg) => /<= 65535/.test(msg)), + `a port above 65535 must be rejected (got: ${JSON.stringify(result.portTooHighErrors)})`, + ); + assert.ok( + result.portNotIntegerErrors.some((msg) => /integer/.test(msg)), + `a non-integer port must be rejected (got: ${JSON.stringify(result.portNotIntegerErrors)})`, + ); + assert.ok( + result.emptyDatabaseUrlErrors.some((msg) => /fewer than 1/.test(msg)), + `an empty databaseUrl must be rejected (got: ${JSON.stringify(result.emptyDatabaseUrlErrors)})`, + ); + + // When the package is built (the CI job builds it), the compiled + // validateConfig boundary behaves identically. + if (existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'))) { + assert.equal(result.boundaryFullValid, true, 'validateConfig must accept a full config'); + assert.ok( + result.boundaryMissingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)), + `validateConfig must reject a missing secret naming the field (got: ${JSON.stringify(result.boundaryMissingSecretErrors)})`, + ); + assert.ok( + result.boundaryUnknownPropertyErrors.some((msg) => /additional properties/.test(msg)), + `validateConfig must reject an unknown property (got: ${JSON.stringify(result.boundaryUnknownPropertyErrors)})`, + ); + } + } finally { + rmSync(probeFile, { force: true }); + } +}); diff --git a/tests/strict-tsconfig.test.mjs b/tests/strict-tsconfig.test.mjs index 965e25a..6a16269 100644 --- a/tests/strict-tsconfig.test.mjs +++ b/tests/strict-tsconfig.test.mjs @@ -37,7 +37,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); /** The workspace packages that must compile under the strict base config. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example']; /** The strict-family flags the committed base config must set. */ const STRICT_FAMILY_FLAGS = [ diff --git a/tests/typescript-pin.test.mjs b/tests/typescript-pin.test.mjs index 431a6e2..1d97a64 100644 --- a/tests/typescript-pin.test.mjs +++ b/tests/typescript-pin.test.mjs @@ -33,7 +33,7 @@ const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const PINNED_TYPESCRIPT = '6.0.3'; /** Every workspace package that must resolve the pinned TypeScript version. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example']; // --------------------------------------------------------------------------- // Tests diff --git a/tests/workspace-config.test.mjs b/tests/workspace-config.test.mjs index 45b95a5..281d96f 100644 --- a/tests/workspace-config.test.mjs +++ b/tests/workspace-config.test.mjs @@ -28,7 +28,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*']; -const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; +const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example']; const PINNED_PNPM = '11.23.0'; // --------------------------------------------------------------------------- diff --git a/tests/workspace-layout.test.mjs b/tests/workspace-layout.test.mjs index 6c1eaa0..762c3d7 100644 --- a/tests/workspace-layout.test.mjs +++ b/tests/workspace-layout.test.mjs @@ -38,6 +38,7 @@ const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions']; const EXPECTED_PACKAGES = { '@personal-blog/server': 'apps/server', '@personal-blog/core': 'packages/core', + '@personal-blog/config': 'packages/config', '@personal-blog/database-postgres': 'packages/database-postgres', '@personal-blog/example-extension': 'extensions/example', };