Skip to content

Replace hand-rolled config normalization/validation with a modern schema lib (zod) #39

Description

@kfastov

Problem

core/config.js hand-rolls config normalization + validation (~50 lines): normalizeValue (trim), normalizeBoolean (parse true/1/yes/y/on), per-key alias resolution (nested mcp.host / flat mcpHost / snake mcp_host), port coercion, per-section defaults, and a validateConfig that returns a missing[] array. It's exactly the "reinvent the wheel" glue we keep growing — and it tempts over-abstraction (we recently added then reverted a normalizeServerSection helper because parameterizing two sections cost more than it saved).

Per project philosophy — prefer modern, popular, well-maintained, great-DX packages (the way got superseded request, or mtcute is a fresher take than traditional MTProto libs) over old "battle-tested but poor-DX" ones — we should replace this glue with a declarative schema lib.

Goal

Replace the ~50 lines of normalization + validation in core/config.js with a declarative schema. Keep the existing ~25-line loader/saver (file IO + resolveStoreDir() + ENOENT→null) — that part is already clean and framework-agnostic. This is a schema-validation problem, not a config-framework problem.

Recommended approach: zod (v4)

Live npm data (2026-06-02): zod v4.4.3, ~185M downloads/week, 0 runtime deps, MIT, actively released. Highest-fidelity match to all our needs and the ecosystem default.

Sketch:

  • Aliasesz.preprocess(raw => canonicalKeys(raw), schema) folds the mcp.host/mcpHost/mcp_host trio into canonical keys in one step (replaces normalizeConfig).
  • Trim / defaults / portsz.string().trim(), .default('127.0.0.1') / .default(false), z.coerce.number().int().positive().
  • Booleans → a small z.preprocess/z.transform for the true/1/yes/on set. ⚠️ Do NOT use z.coerce.boolean() — it's plain truthiness so "false"true (zod#3924).
  • Errorsschema.safeParse() + z.prettifyError(err) for clear multi-line messages (replaces the missing[] array with better UX).
  • Plain-JS ESM works today; we just forgo static type inference (which we don't use anyway).

Runner-up: valibot (v1)

v1.4.1, ~11.8M/week, 0 deps, functional pipe API. Functionally equal; pick it only if we wanted its smaller footprint/style (bundle size is irrelevant for a Node CLI). Slightly more verbose for the nested alias-merge.

Why NOT a config framework

conf (sindresorhus), c12 (unjs), cosmiconfig, convict, node-config/rc solve the wrong axis — managed stores / multi-source layering / file discovery — for a tool that uses one JSON file at a known path with an env override. They'd add the file-location behavior we deliberately customized and still leave us hand-rolling alias mapping. Only revisit conf if we later want a managed, migrating, atomically-written store.

Acceptance criteria

  • normalizeConfig + validateConfig reimplemented declaratively via the chosen schema lib; behavior preserved (aliases, trim, boolean/port coercion, per-section defaults, required apiId/apiHash/phoneNumber).
  • Existing config tests stay green (tests/config-control.test.js, tests/cli-auth.test.js, any mcp/control normalization tests) — normalized output byte-identical for the covered cases.
  • Clear validation errors (e.g. apiId is required, control.port must be a positive integer).
  • The loader/saver (file IO + store dir + ENOENT) left intact.
  • Dependency pinned; one-line note documenting the z.coerce.boolean footgun avoided.

Supply-chain note

zod is single-maintainer (colinhacks) but 0 runtime deps → minimal auditable surface, and ~185M weekly downloads = heavy scrutiny; pin the version. valibot similar (single maintainer, 0 deps, fewer eyes). conf would pull ~9 transitive deps (ajv chain). Figures fetched live from registry.npmjs.org / api.npmjs.org on 2026-06-02.

Scope

Standalone cleanup PR — not part of the #30 backfill work. The current control.* config (added in PR-2a) and mcp.* are the two sections to model.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions