Compare commits

...
4 Commits
Author SHA1 Message Date
kpcto ecc945ce65 Merge pull request '[E00-S04-T01] TypeBox/Ajv schema' (#396) from feature/182 into main
CI / Frozen lockfile install (push) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 30s
CI / Migration ledger (E00-S03-T03) (push) Successful in 45s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 44s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 49s
CI / App readiness after migrations (E00-S03-T06) (push) Successful in 56s
CI / TypeBox/Ajv config schema (E00-S04-T01) (push) Successful in 53s
CI / Compose config (E00-S03-T01) (push) Successful in 25s
2026-08-30 02:42:55 +00:00
implementer 5eff1580ac docs: document the config package in the non-container guide (E00-S04-T01)
CI / Frozen lockfile install (pull_request) Successful in 47s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 26s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 31s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 41s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 42s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 57s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 49s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
2026-08-30 02:29:52 +00:00
implementer ce0d3c0d44 test: lock in the config schema with static, mutation and deterministic probes (E00-S04-T01)
tests/config-schema.test.mjs covers both acceptance criteria: the schema
is defined with TypeBox/Ajv (static assertions on the committed package —
golden-tuple exact pins, Type.Object schema, Ajv compile, boundary
re-exports — each backed by a mutation probe proving non-vacuity) and the
schema covers the validated config fields (host, port, databaseUrl,
sessionSecret with their constraints). The deterministic probe executes
the issue's test plan — 'validate a full config against the TypeBox/Ajv
schema' — against the committed schema through Ajv via Node type
stripping (no build step), plus the negative cases (missing required field
naming sessionSecret, secret too short, unknown property, port bounds, and
empty databaseUrl); when the package is built (as in the CI job) it also
exercises the compiled validateConfig boundary exactly as the later
adapter will consume it.
2026-08-30 02:29:52 +00:00
implementer bcea489391 feat: add TypeBox/Ajv config schema package to the workspace (E00-S04-T01)
Adds packages/config (@personal-blog/config) — the EPPP configuration
service foundation. The package defines the configuration schema with
TypeBox (configSchema: host, port, databaseUrl, sessionSecret — the
validated config fields, golden-tuple pins @sinclair/typebox@0.34.52 and
ajv@8.20.0) and compiles it with Ajv (validateConfig). The environment
adapter (T04), field-specific startup errors (T02) and secret redaction
(T03) build on this boundary in later tasks; nothing reads process.env yet.

Wiring for the new workspace package: lockfile importer + resolved
typebox/ajv tree, apps/server/Dockerfile manifest copy (frozen in-image
install must match the lockfile importers), config-schema CI job, package
set fixtures (workspace-layout, workspace-config, strict-tsconfig,
typescript-pin), probe-file gitignore entry.
2026-08-30 02:29:52 +00:00
15 changed files with 649 additions and 9 deletions
+27
View File
@@ -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
+4
View File
@@ -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
+3 -2
View File
@@ -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
+5 -4
View File
@@ -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
```
+23
View File
@@ -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"
}
}
}
+14
View File
@@ -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';
+52
View File
@@ -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<typeof configSchema>;
+49
View File
@@ -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 };
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+45
View File
@@ -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: {}
+415
View File
@@ -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 });
}
});
+1 -1
View File
@@ -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 = [
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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';
// ---------------------------------------------------------------------------
+1
View File
@@ -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',
};