A pattern for keeping structured data correct everywhere it can be touched, using one JSON Schema enforced at three gates by a single ~50-line zero-dependency validator that runs identically in the browser and Node.
Bad data then can't (1) leave the editing UI, (2) get committed/deployed, or (3) pass CI — all checked against the same file, so the rule can never drift between them.
Built for a Solid-pod + Bitcoin-anchored app, but the validation pattern is standalone and stack-agnostic.
This is the seed of a full worked example. The same
validate.jsis now the first gate of a live, cloneable web contract — a Bitcoin-anchored prediction pool with a reducer, a webledgers.org share ledger, a gitmark trail, and a verifier that replays it all:git clone https://melvin.me/public/worldcup(readAGENTS.md). See See it taken all the way at the bottom.
schema/x.schema.json <- the single source of truth (plain JSON Schema, draft-07 subset)
│
┌────┴─────────────────────────────┐
│ imports/reads │
validate.js (pure, ESM) ── export function validate(data, schema) -> string[]
│ runs in BOTH browser and Node, ZERO dependencies, NO I/O
│
├── Gate 1 BROWSER : editor `import { validate }`, fetch the schema, validate BEFORE the write
├── Gate 2 TOOL : deploy script runs `npm test` before it commits/anchors; aborts on failure
└── Gate 3 CI : validate-cli.js (Node-only, reads files) <- `npm test`
The whole point: one schema object, validated by one function, at every gate. No duplicated rules, no "the client says ok but the server rejects."
- Split pure-validator from file-I/O. ESM
importis static and hoisted — you cannot conditionallyimport 'node:fs'inside a module the browser also loads. Sovalidate.jsis pure (no Node builtins); a separatevalidate-cli.jsdoes the file reading for CI. The browser only ever imports the pure one. (If you used CommonJS you couldrequire('fs')inside a guard, but ESM forbids the conditional import — hence the split.) - Zero dependencies. It's a hand-rolled JSON-Schema subset checker — no ajv, no
node_modules. Small, auditable, browser-safe, copy-pasteable. - Fetch the same schema in the browser that the CLI reads. One file, one rule, three call sites.
// Zero-dependency JSON Schema (subset) checker — pure, runs in the browser (import) and Node (import).
// validate(data, schema) -> array of human-readable error strings ([] = valid).
// Supported keywords: type, required, properties, additionalProperties (schema), items, enum, pattern, minimum, maximum, minItems, maxItems, minLength.
function typeOf(v) {
if (Array.isArray(v)) return 'array';
if (v === null) return 'null';
return typeof v;
}
function typeOk(v, t) {
if (t === 'integer') return Number.isInteger(v);
if (t === 'number') return typeof v === 'number';
if (t === 'object') return v && typeof v === 'object' && !Array.isArray(v);
return typeOf(v) === t;
}
/** Validate `data` against `schema`. Returns an array of human-readable error strings ([] = valid). */
export function validate(data, schema, p) {
p = p || '';
const errs = [];
const at = p || 'root';
if (schema.type && !typeOk(data, schema.type)) {
errs.push(`${at}: expected ${schema.type}, got ${typeOf(data)}`);
return errs; // type wrong — deeper checks would be noise
}
if (schema.enum && !schema.enum.includes(data)) {
errs.push(`${at}: ${JSON.stringify(data)} not in [${schema.enum.join(', ')}]`);
}
if (schema.type === 'string') {
if (schema.pattern && !new RegExp(schema.pattern).test(data)) errs.push(`${at}: "${data}" does not match /${schema.pattern}/`);
if (schema.minLength != null && data.length < schema.minLength) errs.push(`${at}: shorter than minLength ${schema.minLength}`);
}
if (schema.type === 'integer' || schema.type === 'number') {
if (schema.minimum != null && data < schema.minimum) errs.push(`${at}: ${data} < minimum ${schema.minimum}`);
if (schema.maximum != null && data > schema.maximum) errs.push(`${at}: ${data} > maximum ${schema.maximum}`);
}
if (schema.type === 'array') {
if (schema.minItems != null && data.length < schema.minItems) errs.push(`${at}: ${data.length} items < minItems ${schema.minItems}`);
if (schema.maxItems != null && data.length > schema.maxItems) errs.push(`${at}: ${data.length} items > maxItems ${schema.maxItems}`);
if (schema.items) data.forEach((v, i) => errs.push(...validate(v, schema.items, `${at}[${i}]`)));
}
if (schema.type === 'object') {
for (const r of schema.required || []) if (!(r in data)) errs.push(`${at}: missing required "${r}"`);
for (const [k, sub] of Object.entries(schema.properties || {})) {
if (k in data) errs.push(...validate(data[k], sub, p ? `${p}.${k}` : k));
}
// additionalProperties as a schema: validate every key not listed in `properties` (e.g. dynamic maps)
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
const known = schema.properties || {};
for (const [k, v] of Object.entries(data)) {
if (!(k in known)) errs.push(...validate(v, schema.additionalProperties, p ? `${p}.${k}` : k));
}
}
}
return errs;
}Supported keywords: type (string|number|integer|boolean|object|array|null), required, properties, additionalProperties (as a subschema — validates dynamic-key maps, e.g. { "<id>": [int, int] }), items, enum, pattern, minimum, maximum, minItems, maxItems, minLength. No $ref — inline repeated subschemas (or add $ref resolution: pass the root schema down and resolve #/... paths). Extend the keyword list as your data needs.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["results"],
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"required": ["match", "home", "away", "score"],
"properties": {
"match": { "type": "integer", "minimum": 1, "maximum": 104 },
"date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"home": { "type": "string", "minLength": 1 },
"away": { "type": "string", "minLength": 1 },
"score": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": "integer", "minimum": 0 } },
"live": { "type": "boolean" }
}
}
}
}
}<script type="module">
import { validate } from './scripts/validate.js';
const [data, schema] = await Promise.all([
fetch('data/results.json').then(r => r.json()),
fetch('schema/results.schema.json').then(r => r.json()),
]);
async function save(updated) {
const errs = validate(updated, schema); // <-- same validator, same schema
if (errs.length) throw new Error('invalid (' + errs[0] + ')');
const res = await fetch('data/results.json', { method: 'PUT', body: JSON.stringify(updated) });
if (!res.ok) throw new Error('HTTP ' + res.status);
}
</script>The editor refuses to write malformed data — the user sees the schema error, the server never gets it.
Whatever ships your code runs npm test first and aborts if it fails (so you never commit/deploy/anchor invalid data):
import { spawnSync } from 'node:child_process';
const ok = spawnSync('npm test', { shell: true, stdio: 'inherit' }).status === 0;
if (!ok) { console.error('check failed — not shipping'); process.exit(1); }
// ...then commit / push / deployThe pure validator + file reading. This is the only file that touches fs, which is why the browser can import validate.js safely.
// validate-cli.js — run by `npm test`
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { validate } from './validate.js';
const CHECKS = [
['data/results.json', 'schema/results.schema.json'],
// add every data file + its schema
];
let failed = 0;
for (const [dataFile, schemaFile] of CHECKS) {
const data = JSON.parse(readFileSync(join(process.cwd(), dataFile), 'utf8'));
const schema = JSON.parse(readFileSync(join(process.cwd(), schemaFile), 'utf8'));
const errs = validate(data, schema);
if (errs.length) { console.error(`✗ ${dataFile}`); errs.forEach(e => console.error(' ' + e)); failed++; }
else console.log(`✓ ${dataFile} valid`);
}
process.exit(failed ? 1 : 0);// package.json
{ "type": "module", "scripts": { "test": "node scripts/validate-cli.js" } }package.json:"type": "module","scripts": { "test": "node scripts/validate-cli.js" }.- Drop in
scripts/validate.js(above, verbatim). Pure, zero-dep, ESM. - Write
schema/<name>.schema.jsonfor each data file (JSON Schema, the subset above). scripts/validate-cli.js: list[dataFile, schemaFile]pairs,import { validate }, exit non-zero on any error.- Browser: serve
.jswith a JS MIME type,import { validate }in a<script type="module">,fetchthe schema,validate()before any write. - Deploy: run
npm testbefore commit/deploy; abort on failure.
That's it: ~50 lines of validator + one schema per file + three call sites. The invariant you get is one rule, enforced at the UI, the pipeline, and CI, that physically cannot diverge because all three read the same schema and run the same function.
Note: this validates structure (shape, types, ranges, patterns) — not semantics (cross-field/cross-file invariants) or state transitions. For those, add a separate reducer; the schema is the type/ABI layer, not the business logic.
This gist is the seed. For a complete worked example that grows the same pattern into a money-handling, Bitcoin-anchored prediction pool — and that an LLM can clone and learn from end to end — clone:
git clone https://melvin.me/public/worldcup
(It's a Solid pod served over git-over-HTTP; you can also just browse the files at that URL.) Read AGENTS.md first — it's written for exactly this reader. Inside you'll find this same validate.js at all three gates, plus the layers this gist deliberately stops short of:
- a reducer —
score.js(3/1/0 scoring) andledger.js(the money): thetransition()/ business logic the schema can't express. - a share ledger —
pool/ledger.json, a webledgers.org-shaped balance file: gifts + per-match prizes, derived (and re-derivable), not trusted. - a Bitcoin trail —
blocktrails.json: every state committed via gitmark taproot single-use seals, so the log can't be quietly rewritten. - a verifier —
verify.js: replays the public reducers over the anchored data and reproduces the leaderboard + balances, so anyone catches a dishonest operator.
Same idea, scaled: one schema as the ABI, one honest oracle, everything else replayable. That's a web contract — and the validator above is its first gate.
From this (validation) → smart contracts → RGB-style contracts
This gist is only the
validate()/ ABI layer — it checks shape. Here's the path to actual contracts, and how far each step goes.Step up: a publicly-auditable smart contract (operator-run)
Add four things on top of the validator — all client-side / data, ~zero server code:
state.jsonwith{ seq, prev: sha256(jcs(prevState)) }. The prev-hash link makes history tamper-evident.transition(state, move) → { state, effects }. Validates legal moves (not just shape: "no double-spend, no move after lock") and emitseffectslike{ op:'credit', who, amount }. Same discipline asvalidate.js: pure, runs in browser + Node.What you get: validity is publicly verifiable. The operator runs the reducer and applies ledger effects but can't fake a payout — replay catches it. Trust collapses to one honest (or honest-or-caught) oracle for real-world inputs the chain can't see (a score, a price — every smart contract has this), plus an operator trusted only for liveness + custody, both catchable and reducible (escrow / multisig).
Server cost: ~0. The "server" stays a dumb store for the JSON + a balance ledger; it never executes the contract. (One optional endpoint only if you want self-serve enforcement instead of operator-run.)
This is "client-side validation" in the Bitcoin sense (Peter Todd / RGB lineage): state + validation live off-chain with the parties; the chain holds only commitments and prevents double-spends via single-use seals.
Full leap: RGB-style client-side validation
The above is operator-run + publicly-auditable. RGB-grade goes further — a different order of magnitude:
The jump operator-run → RGB is: public shared log → private per-party histories; one operator → none; one oracle → m-of-n. Each is real work (provenance graphs, seal management, P2P data exchange, selective disclosure). It buys trust-minimization + privacy you mostly don't need for, say, a friends' prediction pool — but it's the same lineage, turned up.
Honest summary
validate()The throughline: push all trust into one place (the oracle), make everything else replayable. The validator in this gist is brick one of exactly that.