feat: missing required setting fails startup with a field-specific error (E00-S04-T02)

- packages/config: add src/startup.ts exposing assertValidConfig (builds on
  the T01 TypeBox/Ajv schema) and the field-specific startup errors
  (MissingRequiredSettingError names the missing field; ConfigStartupError
  names each violating field); re-export from the package boundary
- apps/server: validate the startup configuration (including the required
  EPPP_SESSION_SECRET) before the server binds, so a missing required
  setting crashes the process at startup naming the field; depends on
  @personal-blog/config
- compose.yaml: provide EPPP_SESSION_SECRET for the app service (dev-only
  >= 32 char default; override via .env / shell)
- Dockerfile: ship the compiled packages/config in the image (build source +
  runtime dist), matching the server's new workspace dependency
- pnpm-lock.yaml: apps/server importer gains @personal-blog/config
This commit is contained in:
implementer
2026-08-30 03:00:40 +00:00
parent ecc945ce65
commit 0ce790fca3
9 changed files with 200 additions and 23 deletions
+11 -3
View File
@@ -3,12 +3,20 @@
*
* [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.
* schema-validation entry point (`validateConfig`).
*
* [E00-S04-T02] Field-specific startup error: the boundary also exposes the
* startup validation entry point (`assertValidConfig`) and its field-specific
* errors (`MissingRequiredSettingError` — names the missing required setting —
* and `ConfigStartupError` — names each violating field), so the application
* fails fast at startup when a required setting is missing.
*
* The environment adapter (E00-S04-T04) 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';
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
+128
View File
@@ -0,0 +1,128 @@
/**
* EPPP configuration startup validation — [E00-S04-T02] missing required
* setting gives a field-specific startup error.
*
* Builds on the E00-S04-T01 boundary (`configSchema` from `schema.ts`,
* validated with Ajv): `assertValidConfig` is the startup entry point the
* application calls with its parsed configuration before it binds — when a
* required setting is missing it throws `MissingRequiredSettingError`, whose
* message and `missingField` name the missing field (the issue's acceptance:
* "missing required setting gives a field-specific startup error", "the error
* names the missing field"); any other schema violation throws a
* `ConfigStartupError` whose message names each violating field too.
*
* This is deliberately NOT the T04 environment adapter: nothing here reads
* `process.env`. The adapter (E00-S04-T04) maps the environment onto the
* validated config shape and passes it to `assertValidConfig` at startup;
* secret redaction (E00-S04-T03) builds on the same boundary in a later task.
*
* Rollback note from the issue: revert the validation error handling.
*/
import { Ajv, type ErrorObject } from 'ajv';
import { configSchema, type Config } 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 field-specific startup error thrown when a configuration value is
* invalid at startup (any schema violation). `violations` holds one
* field-prefixed message per violation (e.g. `"sessionSecret: must NOT have
* fewer than 32 characters"`), so the error names the offending field(s).
*/
export class ConfigStartupError extends Error {
/** Field-prefixed messages naming each violation (never empty). */
readonly violations: ReadonlyArray<string>;
constructor(message: string, violations: readonly string[]) {
super(message);
this.name = 'ConfigStartupError';
this.violations = violations;
}
}
/**
* The error thrown when a required setting is missing — the E00-S04-T02
* field-specific startup error. `missingField` and the message name the
* missing field (e.g. `"missing required setting: sessionSecret"`), so an
* operator starting the app with an incomplete configuration sees exactly
* which setting to provide.
*/
export class MissingRequiredSettingError extends ConfigStartupError {
/** The name of the required setting that is missing. */
readonly missingField: string;
constructor(missingField: string) {
super(`missing required setting: ${missingField}`, [`missing required setting: ${missingField}`]);
this.name = 'MissingRequiredSettingError';
this.missingField = missingField;
}
}
/**
* Formats one Ajv violation as a field-specific message: the field named by
* the error's `instancePath` (e.g. `/sessionSecret`) prefixes the Ajv
* message, so every startup error names the offending setting — never just a
* bare schema message. An `additionalProperties` violation points at the
* object (empty `instancePath`), so its offending key (Ajv
* `params.additionalProperty`) is used as the field instead.
*/
function formatViolation(error: ErrorObject): string {
const field = error.instancePath.replace(/^\//, '');
const message = error.message ?? 'invalid';
if (field !== '') {
return `${field}: ${message}`;
}
const extra = (error.params as { additionalProperty?: unknown } | undefined)?.additionalProperty;
return typeof extra === 'string' && extra.length > 0 ? `${extra}: ${message}` : message;
}
/**
* Validates a configuration value at startup and returns it as the typed
* `Config` — or throws a field-specific startup error:
*
* - a missing required setting throws `MissingRequiredSettingError` naming
* the missing field (the issue's acceptance criteria);
* - any other schema violation throws `ConfigStartupError` whose message
* names the violating field(s).
*
* The application calls this before it starts serving, so an invalid
* configuration fails fast at startup with a clear, field-specific error
* instead of booting with a silently-wrong setting.
*
* @param value - the parsed configuration value (the T04 adapter will hand
* this the mapped environment)
* @returns the validated configuration
* @throws {MissingRequiredSettingError} when a required setting is missing
* @throws {ConfigStartupError} when the configuration violates the schema
*/
export function assertValidConfig(value: unknown): Config {
const valid = validateConfigValue(value);
if (valid) {
return value as Config;
}
const errors = validateConfigValue.errors ?? [];
// Missing required settings get the dedicated field-specific error — the
// Ajv `required` keyword error carries the missing property name, which is
// exactly the field the acceptance criteria require the error to name.
const missingFields = errors
.filter((error) => error.keyword === 'required')
.map((error) => {
const missing = (error.params as { missingProperty?: unknown } | undefined)?.missingProperty;
return typeof missing === 'string' ? missing : '';
})
.filter((field) => field.length > 0);
if (missingFields.length > 0) {
throw new MissingRequiredSettingError(missingFields.join(', '));
}
const violations = errors.map(formatViolation);
throw new ConfigStartupError(`invalid configuration: ${violations.join('; ')}`, violations);
}
+3 -3
View File
@@ -8,9 +8,9 @@
*
* 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.
* with the Ajv messages) and performs no startup wiring — the startup error
* formatting (E00-S04-T02, `startup.ts`) and the environment adapter
* (E00-S04-T04) build on this raw outcome in their own modules.
*/
import { Ajv } from 'ajv';