From bcea4893916b2519d1f689ea6ceb1e2e7e1928e3 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 02:29:52 +0000 Subject: [PATCH] feat: add TypeBox/Ajv config schema package to the workspace (E00-S04-T01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/ci.yml | 27 +++++++++++++++++ .gitignore | 4 +++ apps/server/Dockerfile | 5 ++-- packages/config/package.json | 23 +++++++++++++++ packages/config/src/index.ts | 14 +++++++++ packages/config/src/schema.ts | 52 +++++++++++++++++++++++++++++++++ packages/config/src/validate.ts | 49 +++++++++++++++++++++++++++++++ packages/config/tsconfig.json | 8 +++++ pnpm-lock.yaml | 45 ++++++++++++++++++++++++++++ tests/strict-tsconfig.test.mjs | 2 +- tests/typescript-pin.test.mjs | 2 +- tests/workspace-config.test.mjs | 2 +- tests/workspace-layout.test.mjs | 1 + 13 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/schema.ts create mode 100644 packages/config/src/validate.ts create mode 100644 packages/config/tsconfig.json 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/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/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', };