Skip to content

Instantly share code, notes, and snippets.

@melvincarvalho
Last active June 14, 2026 12:05
Show Gist options
  • Select an option

  • Save melvincarvalho/5f6dfccdbb4aa112403adda83db61996 to your computer and use it in GitHub Desktop.

Select an option

Save melvincarvalho/5f6dfccdbb4aa112403adda83db61996 to your computer and use it in GitHub Desktop.
One schema, three gates: portable zero-dep client-side JSON-Schema validation (browser + Node, ESM) — for LLMs to replicate · the first gate of a cloneable web contract

One schema, three gates: portable client-side validation

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.js is 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 (read AGENTS.md). See See it taken all the way at the bottom.


Architecture

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."


Three design constraints (the replication gotchas)

  1. Split pure-validator from file-I/O. ESM import is static and hoisted — you cannot conditionally import 'node:fs' inside a module the browser also loads. So validate.js is pure (no Node builtins); a separate validate-cli.js does the file reading for CI. The browser only ever imports the pure one. (If you used CommonJS you could require('fs') inside a guard, but ESM forbids the conditional import — hence the split.)
  2. Zero dependencies. It's a hand-rolled JSON-Schema subset checker — no ajv, no node_modules. Small, auditable, browser-safe, copy-pasteable.
  3. Fetch the same schema in the browser that the CLI reads. One file, one rule, three call sites.

validate.js (the entire validator — copy verbatim)

// 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.


Example schema (schema/results.schema.json)

{
  "$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" }
        }
      }
    }
  }
}

Gate 1 — Browser: validate before the write

<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.


Gate 2 — Deploy tool: validate before commit/anchor

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 / deploy

Gate 3 — CI: validate-cli.js (Node-only entry)

The 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" } }

Replicate it (checklist)

  1. package.json: "type": "module", "scripts": { "test": "node scripts/validate-cli.js" }.
  2. Drop in scripts/validate.js (above, verbatim). Pure, zero-dep, ESM.
  3. Write schema/<name>.schema.json for each data file (JSON Schema, the subset above).
  4. scripts/validate-cli.js: list [dataFile, schemaFile] pairs, import { validate }, exit non-zero on any error.
  5. Browser: serve .js with a JS MIME type, import { validate } in a <script type="module">, fetch the schema, validate() before any write.
  6. Deploy: run npm test before 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.


See it taken all the way — a live web contract

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 reducerscore.js (3/1/0 scoring) and ledger.js (the money): the transition() / business logic the schema can't express.
  • a share ledgerpool/ledger.json, a webledgers.org-shaped balance file: gifts + per-match prizes, derived (and re-derivable), not trusted.
  • a Bitcoin trailblocktrails.json: every state committed via gitmark taproot single-use seals, so the log can't be quietly rewritten.
  • a verifierverify.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.

@melvincarvalho

Copy link
Copy Markdown
Author

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:

  1. State + hash chainstate.json with { seq, prev: sha256(jcs(prevState)) }. The prev-hash link makes history tamper-evident.
  2. A reducertransition(state, move) → { state, effects }. Validates legal moves (not just shape: "no double-spend, no move after lock") and emits effects like { op:'credit', who, amount }. Same discipline as validate.js: pure, runs in browser + Node.
  3. A commitment / single-use seal — anchor each state hash to a chain. On Bitcoin: a taproot UTXO chain where each commit spends the previous one and commits the new state hash, so history can't fork without an on-chain double-spend everyone can see. The chain holds a hash, never the data.
  4. A replay verifier — anyone fetches the off-chain state log + the public reducer, replays it, and checks each state hash against the on-chain commitments. Cheating in the computation becomes catchable by anyone.

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:

  1. Per-party validation & state. Each party holds and validates their own slice of history (their coin's provenance), not a shared public log. You validate what you receive before accepting it, and reject invalid transfers. No operator.
  2. Seal per transition (not per batch/commit) — every move binds to its own single-use seal; an ownership transfer = closing a seal.
  3. Privacy. Data isn't public — only commitments hit the chain. Counterparties exchange validation data peer-to-peer; the world sees nothing.
  4. m-of-n / decentralized oracle. The single oracle becomes multiple attestors, so no one party is the trust root.

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

Trust footprint Where it runs Effort from validate()
This gist n/a (shape only) client + CI
Operator-run contract 1 honest-or-caught oracle + an auditable operator client + data; server ≈ dumb store ~a few hundred LOC: reducer, state + hash chain, anchor, replay verifier
RGB-style m-of-n oracle; no operator per-party clients; chain = commitments only a different project: provenance, seal-per-tx, privacy, P2P

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment