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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+45
@@ -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: {}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user