fix(seed-contract): address review round 2 — NaN/empty-string validation, Error cause, parity CI wiring

P2 — Non-finite ttlSeconds/maxStaleMin bypassed validation.
`typeof NaN === 'number'` and `NaN > 0 === false` meant a NaN duration
passed the old typeof+<=0 checks and would have poisoned TTLs once
validateDescriptor is wired into runSeed. Now gated by Number.isFinite,
which rejects NaN and ±Infinity. Tests added for NaN/Infinity on both
fields.

P2 — Empty/whitespace-only strings for domain/resource/canonicalKey/sourceVersion
bypassed validation. Added .trim() === '' rejection + tests per field.
This mattered because canonicalKey='' would have landed writes at the
empty key and seed-meta under a blank resource namespace.

P3 — SeedContractError silently dropped the Error v2 cause option.
Constructor now forwards { cause } through super() so err.cause works
with standard tooling (Node's default stack printer, Sentry chained-cause
serialization). resolveRecordCount's manual err.cause = err assignment
was replaced with the options-bag form. Test added for both constructor
direct-use and the resolveRecordCount wrap path.

P3 — Parity verifier was not on an automated path. Added
tests/seed-envelope-parity.test.mjs which spawns scripts/verify-seed-envelope-parity.mjs
via execFile; non-zero exit (drift) → test fails. Now runs as part of
`npm run test:data` (tsx --test tests/*.test.mjs). Drift injection
confirmed: sed -i modifying api/_seed-envelope.js makes the test fail
with 'Command failed' from execFile.

51 tests total (was 39). All green on clean tree.
This commit is contained in:
Elie Habib
2026-04-14 21:44:37 +04:00
parent 68e4e326dc
commit 99646dd9a4
3 changed files with 94 additions and 9 deletions

View File

@@ -8,8 +8,11 @@
// contract is enforced at runtime. PR 3 hard-fails the conformance test.
export class SeedContractError extends Error {
constructor(message, { descriptor, field } = {}) {
super(message);
constructor(message, { descriptor, field, cause } = {}) {
// Pass `cause` through the standard Error options bag (Node ≥16.9) so the
// usual Error causal-chain tooling works (`err.cause`, Node's default
// stack printer, Sentry's chained-cause serializer).
super(message, cause !== undefined ? { cause } : undefined);
this.name = 'SeedContractError';
this.descriptor = descriptor;
this.field = field;
@@ -82,14 +85,26 @@ export function validateDescriptor(descriptor) {
}
}
if (descriptor.ttlSeconds <= 0) {
throw new SeedContractError('runSeed descriptor ttlSeconds must be > 0', { descriptor, field: 'ttlSeconds' });
// Non-empty-string fields. `typeof 'string'` accepts '' which would let a
// seeder publish to key '' and write seed-meta under a blank resource.
for (const field of ['domain', 'resource', 'canonicalKey', 'sourceVersion']) {
if (descriptor[field].trim() === '') {
throw new SeedContractError(`runSeed descriptor field "${field}" must be a non-empty string`, { descriptor, field });
}
}
// Finite positive numbers. `typeof NaN === 'number'` and `NaN > 0 === false`
// means a NaN ttl/age would pass the typeof+<=0 check and then poison
// expiry/freshness once enforced at runtime. Number.isFinite rejects NaN and
// ±Infinity.
if (!Number.isFinite(descriptor.ttlSeconds) || descriptor.ttlSeconds <= 0) {
throw new SeedContractError('runSeed descriptor ttlSeconds must be a finite number > 0', { descriptor, field: 'ttlSeconds' });
}
if (!Number.isInteger(descriptor.schemaVersion) || descriptor.schemaVersion < 1) {
throw new SeedContractError('runSeed descriptor schemaVersion must be a positive integer', { descriptor, field: 'schemaVersion' });
}
if (descriptor.maxStaleMin <= 0) {
throw new SeedContractError('runSeed descriptor maxStaleMin must be > 0', { descriptor, field: 'maxStaleMin' });
if (!Number.isFinite(descriptor.maxStaleMin) || descriptor.maxStaleMin <= 0) {
throw new SeedContractError('runSeed descriptor maxStaleMin must be a finite number > 0', { descriptor, field: 'maxStaleMin' });
}
if (descriptor.populationMode != null && descriptor.populationMode !== 'scheduled' && descriptor.populationMode !== 'on_demand') {
@@ -121,9 +136,10 @@ export function resolveRecordCount(declareRecords, data) {
try {
count = declareRecords(data);
} catch (err) {
const wrapped = new SeedContractError(`declareRecords threw: ${err && err.message ? err.message : err}`, { field: 'declareRecords' });
wrapped.cause = err;
throw wrapped;
throw new SeedContractError(
`declareRecords threw: ${err && err.message ? err.message : err}`,
{ field: 'declareRecords', cause: err }
);
}
if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
throw new SeedContractError(

View File

@@ -71,6 +71,33 @@ test('validateDescriptor: rejects ttlSeconds <= 0', () => {
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: -5 })), SeedContractError);
});
test('validateDescriptor: rejects non-finite ttlSeconds (NaN, Infinity)', () => {
// `typeof NaN === "number"` and `NaN > 0 === false`, so the old check
// silently accepted it; Number.isFinite is what we actually want.
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: NaN })), SeedContractError);
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: Infinity })), SeedContractError);
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: -Infinity })), SeedContractError);
});
test('validateDescriptor: rejects non-finite maxStaleMin (NaN, Infinity)', () => {
assert.throws(() => validateDescriptor(minimalDescriptor({ maxStaleMin: NaN })), SeedContractError);
assert.throws(() => validateDescriptor(minimalDescriptor({ maxStaleMin: Infinity })), SeedContractError);
});
for (const field of ['domain', 'resource', 'canonicalKey', 'sourceVersion']) {
test(`validateDescriptor: rejects empty string for "${field}"`, () => {
const err = expectThrows(() => validateDescriptor(minimalDescriptor({ [field]: '' })));
assert.ok(err instanceof SeedContractError);
assert.equal(err.field, field);
});
test(`validateDescriptor: rejects whitespace-only string for "${field}"`, () => {
const err = expectThrows(() => validateDescriptor(minimalDescriptor({ [field]: ' ' })));
assert.ok(err instanceof SeedContractError);
assert.equal(err.field, field);
});
}
test('validateDescriptor: rejects non-integer schemaVersion', () => {
assert.throws(() => validateDescriptor(minimalDescriptor({ schemaVersion: 1.5 })), SeedContractError);
assert.throws(() => validateDescriptor(minimalDescriptor({ schemaVersion: 0 })), SeedContractError);
@@ -115,6 +142,13 @@ test('resolveRecordCount: wraps thrown errors with SeedContractError + cause', (
assert.equal(err.cause?.message, 'boom');
});
test('SeedContractError: accepts cause via options bag (Error v2 spec)', () => {
const underlying = new TypeError('inner');
const err = new SeedContractError('wrap', { field: 'declareRecords', cause: underlying });
assert.equal(err.cause, underlying);
assert.equal(err.field, 'declareRecords');
});
test('resolveRecordCount: rejects non-function declareRecords', () => {
assert.throws(() => resolveRecordCount('not a function', {}), SeedContractError);
});

View File

@@ -0,0 +1,35 @@
// Drift check for the seed-envelope helpers.
//
// `scripts/verify-seed-envelope-parity.mjs` diffs function bodies between:
// - scripts/_seed-envelope-source.mjs (source of truth)
// - api/_seed-envelope.js (edge-safe mirror)
//
// This test runs the verifier as a child process during `npm run test:data`
// so drift between the two JS copies fails CI. Without this, someone could
// hand-edit api/_seed-envelope.js and the parity guarantee — which is the
// central invariant PR #3095 introduced — would silently erode.
//
// The TS mirror at server/_shared/seed-envelope.ts is validated by
// `npm run typecheck` and reviewed manually (see header comment in that file).
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const execFileP = promisify(execFile);
const here = dirname(fileURLToPath(import.meta.url));
const verifier = resolve(here, '..', 'scripts', 'verify-seed-envelope-parity.mjs');
test('seed-envelope parity: source ↔ edge mirror stay in sync', async () => {
const { stdout, stderr } = await execFileP(process.execPath, [verifier], {
timeout: 10_000,
});
// Verifier prints a one-line OK on success. Any drift would exit non-zero,
// which execFile surfaces as a thrown error rejecting the promise, so
// reaching this line means the verifier succeeded.
assert.match(stdout, /parity: OK/);
assert.equal(stderr.trim(), '');
});