[E00-S04-T01] TypeBox/Ajv schema #396

Merged
kpcto merged 3 commits from feature/182 into main 2026-08-30 02:42:56 +00:00
Member

What changed

Implements [E00-S04-T01] TypeBox/Ajv schema (#182): the configuration schema for the EPPP configuration service is now defined with TypeBox and validated with Ajv, in a new packages/config workspace package (@personal-blog/config — the config package of the planned repository architecture, Architecture wiki §9).

  • packages/config — new configuration-service package (@personal-blog/config): src/schema.ts defines configSchema with TypeBox (Type.Object, golden-tuple pin @sinclair/typebox@0.34.52) covering the validated config fields: host (optional string, default 0.0.0.0 — the bind address), port (optional integer 1–65535, default 3000 — the existing PORT), databaseUrl (optional non-empty string — the existing DATABASE_URL; absent = the local non-container no-database path), sessionSecret (required, ≥ 32 chars — the story's secret field, EPPP_SESSION_SECRET per Security-and-Operations §32/§26), with additionalProperties: false so an unexpected setting is rejected loudly. src/validate.ts compiles the schema with Ajv (ajv, golden-tuple pin 8.20.0, allErrors: true) and exports validateConfig(value): { valid, errors } — the generic schema-validation entry point; src/index.ts is the package boundary re-exporting both plus the Config (Static<typeof configSchema>) and ConfigValidationResult types. Nothing reads process.env and nothing is wired into startup yet — 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, as the brief requires.
  • pnpm-lock.yaml — importer + resolved tree: packages/config importer plus the exact-pinned @sinclair/typebox@0.34.52 / ajv@8.20.0 dependency graph (ajv's transitive tree: fast-deep-equal, fast-uri, json-schema-traverse, require-from-string), generated by pnpm 11.23.0; pnpm install --frozen-lockfile passes.
  • apps/server/Dockerfile: the build stage now copies packages/config/package.json so the frozen in-image install matches the lockfile importers exactly (the server itself does not depend on config yet — no source/dist copy needed).
  • .gitea/workflows/ci.yml — new config-schema job runs node --test tests/config-schema.test.mjs on every PR (installs the frozen workspace and builds the config package, since the probe also exercises the compiled package boundary). Additive only (no existing job modified, same action majors, no secrets: context, no untrusted interpolation).
  • Package-set fixtures updated for the new workspace package: workspace-layout, workspace-config, strict-tsconfig, typescript-pin (layout ↔ lockfile parity, strict-base compile, pinned-TS resolution).
  • tests/config-schema.test.mjs — new suite locking in both acceptance criteria (details in the criterion → test table): static assertions on the committed package (golden-tuple exact pins, TypeBox schema shape, Ajv compile, boundary re-exports, CI wiring), each backed by mutation probes proving non-vacuity (removing a field, making the secret optional, relaxing the minLength, opening the object, replacing TypeBox/Ajv with hand-rolled code all fail); a deterministic probe executes the issue's test plan — "validate a full config against the TypeBox/Ajv schema" — against the committed src/schema.ts through Ajv via Node type stripping (no build step), 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 (as in the CI job) the same probe exercises the compiled validateConfig boundary exactly as the later configuration adapter will consume it.
  • docs/development/non-container.md: workspace package table and build expectations updated for the new package.
  • .gitignore: .config-schema-probe-*.mjs transient probe files (same pattern as the database-postgres probe files).

Explicitly out of scope per the brief, not touched: field-specific startup error (E00-S04-T02), secret redaction (E00-S04-T03), process.env access rule (E00-S04-T04). The server (apps/server/src/index.ts) is unchanged — it still reads PORT/DATABASE_URL directly until the T04 adapter lands.

Criterion → test table

Acceptance criterion Test (fails without the committed state)
configuration schema is defined with TypeBox/Ajv tests/config-schema.test.mjs — "the config package exists, pins the golden-tuple runtime deps exactly, and builds with tsc" (manifest name @personal-blog/config, dependencies['@sinclair/typebox'] === '0.34.52' and dependencies.ajv === '8.20.0', exact MAJOR.MINOR.PATCH — no ranges); "the configuration schema is defined with TypeBox …" (import { Type, type Static } from '@sinclair/typebox' + export const configSchema = Type.Object(); "the schema is validated with Ajv …" (import { Ajv } from 'ajv', new Ajv({ allErrors: true }), .compile(configSchema), export function validateConfig); "the package boundary re-exports the schema and the validator"; mutation probes "replacing TypeBox with a hand-rolled object …" and "dropping the Ajv compile …" prove the TypeBox/Ajv assertions are non-vacuous
schema covers the validated config fields tests/config-schema.test.mjs — "the configuration schema is defined with TypeBox and covers the validated config fields" (host optional string default 0.0.0.0; port optional integer 1–65535 default 3000; databaseUrl optional non-empty string; sessionSecret required minLength: 32, never Type.Optional; additionalProperties: false); mutation probes "removing a validated config field …", "making the secret optional …", "relaxing the secret length …", "opening the object …"; deterministic probe — a full config (host, port, databaseUrl, sessionSecret) validates against the committed schema through Ajv; host/port/databaseUrl omitted → valid (defaults / no-database path); missing sessionSecret → invalid naming the field; short secret, unknown property, port: 65536, port: '3000', empty databaseUrl → all rejected
validate a full config against the TypeBox/Ajv schema (issue test plan) tests/config-schema.test.mjs — "a full config validates against the committed TypeBox/Ajv schema, and violations are rejected (deterministic probe)": the probe imports the committed packages/config/src/schema.ts (Node type stripping, no build step) and validates a full config through Ajv; when the package is built (the CI job builds it first) the same probe additionally asserts the compiled validateConfig boundary accepts the full config and rejects the missing-secret / unknown-property cases identically
the schema criterion gates merges via the config-schema job tests/config-schema.test.mjs — "the config-schema criterion is enforced in CI" (root test glob covers the suite; .gitea/workflows/ci.yml runs node --test tests/config-schema.test.mjs and builds @personal-blog/config first); .gitea/workflows/ci.yml — config-schema job (additive, matching the security-reviewed #390/#391/#392/#393/#394/#395 precedent)

Test plan executed

  • node --test tests/config-schema.test.mjs → 12 tests, 12 pass / 0 fail / 0 skip on Node 22.23.2. The deterministic probe ran for real: a full config validates; missing sessionSecret is rejected naming the field; short secret / unknown property / out-of-range port / non-integer port / empty databaseUrl are all rejected; and the compiled validateConfig boundary (package built) behaves identically.
  • Full suite (node --test "tests/**/*.test.mjs"): 210 tests — 186 pass / 9 fail / 15 skip; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24): tests/frozen-install.test.mjs ×5 and tests/root-commands.test.mjs ×3 fail on ERR_PNPM_UNSUPPORTED_ENGINE (verified: pnpm install --frozen-lockfile passes under the engine override, and every package compiles under tsc), tests/node-engine.test.mjs ×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass.
  • Frozen install + lockfile: pnpm install --frozen-lockfile succeeds against the regenerated pnpm-lock.yaml (packages/config importer + typebox/ajv tree).
  • Build/typecheck: every workspace package (5/5) compiles with tsc -p … --noEmit exit 0, including the new packages/config (strict base config, NodeNext, verbatimModuleSyntax — no TS errors under TypeScript 6.0.3).
  • Related suites re-run green: workspace-layout (8), workspace-config (6), strict-tsconfig (5), typescript-pin (3), app-readiness (18 pass / 1 docker-skip), health-endpoint (7), architecture-import (10), no-core-extension-imports (3).

Risks / notes

  • Ajv import form: under NodeNext + verbatimModuleSyntax, import { Ajv } from 'ajv' is used instead of the default import — ajv 8.20.0 ships CJS with a dist/ajv.d.ts declaring both a named Ajv class and export default; the named import types and runs correctly under Node's ESM-CJS interop (verified behaviorally).
  • Probe build dependency: the deterministic probe imports the committed src/schema.ts via Node type stripping (no build needed); the compiled-validateConfig assertions run when the package is built — the CI job builds it first, so they are deterministic in CI and skip gracefully on a clean local clone without a build step.
  • Field set provenance: the schema's fields are the E00-S04 validated config fields — port/databaseUrl are the settings the server already consumes (PORT/DATABASE_URL), sessionSecret is the story's required secret (EPPP_SESSION_SECRET, Security-and-Operations §32), and host carries the current hardcoded bind address (0.0.0.0) as its default. T04's adapter maps process.env onto this shape.
  • Rollback note from the issue: revert the schema definition — remove packages/config and the lockfile importer, revert the Dockerfile manifest copy, the config-schema CI job and the fixture updates (no data migration involved).
  • CI workflow change is strictly additive (new job, no existing job modified) and matches the security-reviewed #390–#395 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested.

Refs #182

## What changed Implements [E00-S04-T01] TypeBox/Ajv schema (#182): the configuration schema for the EPPP configuration service is now defined with TypeBox and validated with Ajv, in a new `packages/config` workspace package (`@personal-blog/config` — the `config` package of the planned repository architecture, Architecture wiki §9). - **`packages/config` — new configuration-service package** (`@personal-blog/config`): `src/schema.ts` defines `configSchema` with TypeBox (`Type.Object`, golden-tuple pin `@sinclair/typebox@0.34.52`) covering the **validated config fields**: `host` (optional string, default `0.0.0.0` — the bind address), `port` (optional integer 1–65535, default `3000` — the existing `PORT`), `databaseUrl` (optional non-empty string — the existing `DATABASE_URL`; absent = the local non-container no-database path), `sessionSecret` (**required**, ≥ 32 chars — the story's secret field, `EPPP_SESSION_SECRET` per Security-and-Operations §32/§26), with `additionalProperties: false` so an unexpected setting is rejected loudly. `src/validate.ts` compiles the schema with Ajv (`ajv`, golden-tuple pin `8.20.0`, `allErrors: true`) and exports `validateConfig(value): { valid, errors }` — the generic schema-validation entry point; `src/index.ts` is the package boundary re-exporting both plus the `Config` (`Static<typeof configSchema>`) and `ConfigValidationResult` types. **Nothing reads `process.env` and nothing is wired into startup yet** — 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, as the brief requires. - **`pnpm-lock.yaml` — importer + resolved tree**: `packages/config` importer plus the exact-pinned `@sinclair/typebox@0.34.52` / `ajv@8.20.0` dependency graph (ajv's transitive tree: fast-deep-equal, fast-uri, json-schema-traverse, require-from-string), generated by pnpm 11.23.0; `pnpm install --frozen-lockfile` passes. - **`apps/server/Dockerfile`**: the build stage now copies `packages/config/package.json` so the frozen in-image install matches the lockfile importers exactly (the server itself does not depend on config yet — no source/dist copy needed). - **`.gitea/workflows/ci.yml` — new `config-schema` job** runs `node --test tests/config-schema.test.mjs` on every PR (installs the frozen workspace and builds the config package, since the probe also exercises the compiled package boundary). Additive only (no existing job modified, same action majors, no `secrets:` context, no untrusted interpolation). - **Package-set fixtures updated** for the new workspace package: `workspace-layout`, `workspace-config`, `strict-tsconfig`, `typescript-pin` (layout ↔ lockfile parity, strict-base compile, pinned-TS resolution). - **`tests/config-schema.test.mjs` — new suite locking in both acceptance criteria** (details in the criterion → test table): static assertions on the committed package (golden-tuple exact pins, TypeBox schema shape, Ajv compile, boundary re-exports, CI wiring), each backed by **mutation probes** proving non-vacuity (removing a field, making the secret optional, relaxing the minLength, opening the object, replacing TypeBox/Ajv with hand-rolled code all fail); a **deterministic probe** executes the issue's test plan — "validate a full config against the TypeBox/Ajv schema" — against the committed `src/schema.ts` through Ajv via Node type stripping (no build step), 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 (as in the CI job) the same probe exercises the compiled `validateConfig` boundary exactly as the later configuration adapter will consume it. - **`docs/development/non-container.md`**: workspace package table and build expectations updated for the new package. - **`.gitignore`**: `.config-schema-probe-*.mjs` transient probe files (same pattern as the database-postgres probe files). Explicitly out of scope per the brief, **not touched**: field-specific startup error (E00-S04-T02), secret redaction (E00-S04-T03), `process.env` access rule (E00-S04-T04). The server (`apps/server/src/index.ts`) is unchanged — it still reads `PORT`/`DATABASE_URL` directly until the T04 adapter lands. ## Criterion → test table | Acceptance criterion | Test (fails without the committed state) | | --- | --- | | configuration schema is defined with TypeBox/Ajv | `tests/config-schema.test.mjs` — **"the config package exists, pins the golden-tuple runtime deps exactly, and builds with tsc"** (manifest name `@personal-blog/config`, `dependencies['@sinclair/typebox'] === '0.34.52'` and `dependencies.ajv === '8.20.0'`, exact `MAJOR.MINOR.PATCH` — no ranges); **"the configuration schema is defined with TypeBox …"** (`import { Type, type Static } from '@sinclair/typebox'` + `export const configSchema = Type.Object(`); **"the schema is validated with Ajv …"** (`import { Ajv } from 'ajv'`, `new Ajv({ allErrors: true })`, `.compile(configSchema)`, `export function validateConfig`); **"the package boundary re-exports the schema and the validator"**; mutation probes **"replacing TypeBox with a hand-rolled object …"** and **"dropping the Ajv compile …"** prove the TypeBox/Ajv assertions are non-vacuous | | schema covers the validated config fields | `tests/config-schema.test.mjs` — **"the configuration schema is defined with TypeBox and covers the validated config fields"** (host optional string default `0.0.0.0`; port optional integer 1–65535 default `3000`; databaseUrl optional non-empty string; sessionSecret **required** `minLength: 32`, never `Type.Optional`; `additionalProperties: false`); mutation probes **"removing a validated config field …"**, **"making the secret optional …"**, **"relaxing the secret length …"**, **"opening the object …"**; deterministic probe — a full config (host, port, databaseUrl, sessionSecret) **validates** against the committed schema through Ajv; host/port/databaseUrl omitted → valid (defaults / no-database path); missing `sessionSecret` → invalid **naming the field**; short secret, unknown property, `port: 65536`, `port: '3000'`, empty `databaseUrl` → all rejected | | validate a full config against the TypeBox/Ajv schema (issue test plan) | `tests/config-schema.test.mjs` — **"a full config validates against the committed TypeBox/Ajv schema, and violations are rejected (deterministic probe)"**: the probe imports the committed `packages/config/src/schema.ts` (Node type stripping, no build step) and validates a full config through Ajv; when the package is built (the CI job builds it first) the same probe additionally asserts the compiled `validateConfig` boundary accepts the full config and rejects the missing-secret / unknown-property cases identically | | the schema criterion gates merges via the `config-schema` job | `tests/config-schema.test.mjs` — **"the config-schema criterion is enforced in CI"** (root test glob covers the suite; `.gitea/workflows/ci.yml` runs `node --test tests/config-schema.test.mjs` and builds `@personal-blog/config` first); `.gitea/workflows/ci.yml` — **`config-schema` job** (additive, matching the security-reviewed #390/#391/#392/#393/#394/#395 precedent) | ## Test plan executed - `node --test tests/config-schema.test.mjs` → **12 tests, 12 pass / 0 fail / 0 skip** on Node 22.23.2. The deterministic probe ran for real: a full config validates; missing `sessionSecret` is rejected naming the field; short secret / unknown property / out-of-range port / non-integer port / empty `databaseUrl` are all rejected; and the compiled `validateConfig` boundary (package built) behaves identically. - **Full suite** (`node --test "tests/**/*.test.mjs"`): **210 tests — 186 pass / 9 fail / 15 skip**; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24): `tests/frozen-install.test.mjs` ×5 and `tests/root-commands.test.mjs` ×3 fail on `ERR_PNPM_UNSUPPORTED_ENGINE` (verified: `pnpm install --frozen-lockfile` passes under the engine override, and every package compiles under `tsc`), `tests/node-engine.test.mjs` ×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass. - **Frozen install + lockfile**: `pnpm install --frozen-lockfile` succeeds against the regenerated `pnpm-lock.yaml` (`packages/config` importer + typebox/ajv tree). - **Build/typecheck**: every workspace package (5/5) compiles with `tsc -p … --noEmit` exit 0, including the new `packages/config` (strict base config, NodeNext, verbatimModuleSyntax — no TS errors under TypeScript 6.0.3). - Related suites re-run green: `workspace-layout` (8), `workspace-config` (6), `strict-tsconfig` (5), `typescript-pin` (3), `app-readiness` (18 pass / 1 docker-skip), `health-endpoint` (7), `architecture-import` (10), `no-core-extension-imports` (3). ## Risks / notes - **Ajv import form**: under NodeNext + `verbatimModuleSyntax`, `import { Ajv } from 'ajv'` is used instead of the default import — ajv 8.20.0 ships CJS with a `dist/ajv.d.ts` declaring both a named `Ajv` class and `export default`; the named import types and runs correctly under Node's ESM-CJS interop (verified behaviorally). - **Probe build dependency**: the deterministic probe imports the committed `src/schema.ts` via Node type stripping (no build needed); the compiled-`validateConfig` assertions run when the package is built — the CI job builds it first, so they are deterministic in CI and skip gracefully on a clean local clone without a build step. - **Field set provenance**: the schema's fields are the E00-S04 validated config fields — `port`/`databaseUrl` are the settings the server already consumes (`PORT`/`DATABASE_URL`), `sessionSecret` is the story's required secret (`EPPP_SESSION_SECRET`, Security-and-Operations §32), and `host` carries the current hardcoded bind address (`0.0.0.0`) as its default. T04's adapter maps `process.env` onto this shape. - **Rollback note from the issue**: revert the schema definition — remove `packages/config` and the lockfile importer, revert the Dockerfile manifest copy, the `config-schema` CI job and the fixture updates (no data migration involved). - CI workflow change is strictly additive (new job, no existing job modified) and matches the security-reviewed #390–#395 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested. Refs #182
bot-implementer added 3 commits 2026-08-30 02:30:23 +00:00
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.
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.
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
5eff1580ac
kpcto merged commit ecc945ce65 into main 2026-08-30 02:42:56 +00:00
kpcto deleted branch feature/182 2026-08-30 02:42:57 +00:00
Sign in to join this conversation.