input-event-router
Every interactive app re-hand-rolls the same first step: turn raw, untrusted pointer/key input into something a reducer can safely fold — and that is where the bugs and the attack surface live. input-event-router is that step done once, correctly: a dependency-free deterministic reducer that folds a raw input-event log (JSONL) into a typed, schema-validated event stream, plus an O(1) fold-state (sequence integrity + a monotonic logical tick). seq is a strict total order (a gap or repeat is a rejection, never coerced); t is logical time supplied as data, never a wall-clock read, so the same log replays byte-identically forever — in a browser or headless on Node; type must be in a declared, closed vocabulary; payload is an opaque object it never interprets. It authors the interactive lane's {seq,t,type,payload} event envelope that render-loop-harness and undo-stack-kernel fold. Zero dependencies, pure, offline.
node input-event-router.js < events.jsonl # fold a JSONL event log -> render + emitted-stream summary | node input-event-router.js --help
test_input-event-router.js (25/25) — replay-determinism, seq total-order rejection, closed-vocabulary enforcement, O(1) state bound under a large synthetic log, crash-clean on bad/empty JSON
Zero dependencies, Node or browser, deterministic
input-event-router.js230 lineson GitHub →
#!/usr/bin/env node
/* input-event-router.js — a dependency-free, deterministic reducer that turns a
raw input-event log into a validated, typed event stream.
WHY THIS EXISTS. Every interactive app re-hand-rolls the same first step: take
raw, untrusted pointer/key input and turn it into something a reducer can
safely fold. Hand-rolled, that step is where the bugs and the attack surface
live — an unvalidated event coerced instead of rejected, a wall-clock read
that breaks replay, a field the reducer trusts that the input never carried.
This is that step done once, correctly, as a DETERMINISTIC REDUCER over an
input-event log: raw events in, a typed schema-validated stream out, plus a
tiny O(1) fold-state (sequence integrity + monotonic tick).
THE STRIP-CLEAN RULE (the whole reason to trust it). state_t is a pure fold of
state_0 and the ordered event log up to t. Replaying the same log yields
byte-identical state, stream, and render. The router holds NO clock, NO
network, and NO entropy it did not receive as an event — logical time enters
ONLY as the event field `t`, supplied by the caller as data. So the same log
replays identically forever, in a browser or headless on Node.
THE ENVELOPE (this gift authors the interactive lane's event schema). One
event per JSONL line:
{ "seq": <int>=0, monotonic +1 >, "t": <int>=0, non-decreasing logical tick >,
"type": <one of the declared accepted types>, "payload": <object> }
seq is the total order (a gap or repeat is a rejection). t is logical time as
DATA (never a clock read). type must be in the declared accepted set. payload
is opaque cargo (checked to be an object; its contents are the downstream
reducer's business, not this gift's).
USAGE
node input-event-router.js < events.jsonl # fold a JSONL event log -> render + summary
node input-event-router.js --help
Released under MIT. Its edge, printed in the README and --help: this
NORMALIZES and VALIDATES input events against a declared schema, folding them
into a deterministic typed stream. It does NOT sanitize application semantics,
it does NOT persist, it holds NO clock/network/entropy, and it trusts no event
it did not schema. (Retaining event history is the undo-stack-kernel's job,
under its own declared bound — this gift deliberately keeps O(1) state.)
*/
"use strict";
// The default accepted event-type vocabulary. The vocabulary is CONFIG, not
// frozen (covenant §2/§5 U2 — sub-lanes/types by contract, not by a fixed list):
// what the gift enforces is that the vocabulary is DECLARED and CLOSED, which is
// the decidable security property. An app supplies its own set via cfg.acceptedTypes.
var DEFAULT_ACCEPTED_TYPES = ["pointer", "key", "select", "edit", "commit"];
// The initial fold-state. O(1) by contract: three integers, never a buffer.
function state0() {
return { nextSeq: 0, lastTick: -1, count: 0 };
}
// isPlainObject — an object, not null, not an array, not a scalar. payload must
// be an object; we do NOT walk its contents (depth-1 check => bounded work,
// resource-bounds clause §3.3).
function isPlainObject(v) {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
function isInt(v) {
return typeof v === "number" && isFinite(v) && Math.floor(v) === v;
}
/* validate(state, event, cfg) -> { ok: true } | { ok: false, reason: <string> }
Pure. Checks the envelope against the schema BEFORE the fold sees it. Every
failure is a DECLARED rejection reason; nothing is coerced. */
function validate(state, event, cfg) {
var accepted = (cfg && cfg.acceptedTypes) || DEFAULT_ACCEPTED_TYPES;
if (!isPlainObject(event)) return { ok: false, reason: "event-not-object" };
// seq: present, int >= 0, exactly the next expected (total order, no gap/repeat).
if (!isInt(event.seq) || event.seq < 0) return { ok: false, reason: "seq-not-int" };
if (event.seq < state.nextSeq) return { ok: false, reason: "seq-repeat" };
if (event.seq > state.nextSeq) return { ok: false, reason: "seq-gap" };
// t: present, int >= 0, non-decreasing (logical tick as data; never a clock).
if (!isInt(event.t) || event.t < 0) return { ok: false, reason: "tick-not-int" };
if (event.t < state.lastTick) return { ok: false, reason: "tick-decrease" };
// type: non-empty string in the declared accepted set.
if (typeof event.type !== "string" || event.type.length === 0) {
return { ok: false, reason: "type-not-string" };
}
if (accepted.indexOf(event.type) === -1) return { ok: false, reason: "type-not-accepted" };
// payload: an object (opaque cargo). A scalar/null/array payload is rejected.
if (!isPlainObject(event.payload)) return { ok: false, reason: "payload-not-object" };
return { ok: true };
}
/* route(state, event, cfg) -> { state, emit, reject }
The pure reducer. On a valid event: emit the normalized typed event and
advance state. On an invalid event: emit null, record the rejection, and DO
NOT advance the fold into a corrupt place. The whole log is folded by reduce. */
function route(state, event, cfg) {
var v = validate(state, event, cfg);
if (!v.ok) {
// A rejected event does not advance nextSeq/lastTick — the fold's integrity
// point is preserved. reject carries the offending seq when it was an int.
var seq = isInt(event && event.seq) ? event.seq : null;
return { state: state, emit: null, reject: { seq: seq, reason: v.reason } };
}
// Build the normalized typed event WITHOUT trusting the raw object's prototype
// or extra keys: copy only the four envelope fields onto a null-proto object,
// so a hostile __proto__/constructor payload key cannot pollute anything.
var payloadCopy = Object.create(null);
var keys = Object.keys(event.payload);
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
payloadCopy[k] = event.payload[k];
}
var emit = { seq: event.seq, t: event.t, type: event.type, payload: payloadCopy };
var next = { nextSeq: state.nextSeq + 1, lastTick: event.t, count: state.count + 1 };
return { state: next, emit: emit, reject: null };
}
/* foldLog(events, cfg) -> { state, stream, rejections, halted }
Folds an ordered array of raw events. reject-policy default is HALT: a hostile
or corrupt log STOPS at the first integrity violation rather than silently
dropping bad events and continuing into an ambiguous stream. cfg.rejectPolicy
= "skip" is available for genuinely best-effort input. */
function foldLog(events, cfg) {
var policy = (cfg && cfg.rejectPolicy) || "halt";
var state = state0();
var stream = [];
var rejections = [];
var halted = false;
for (var i = 0; i < events.length; i++) {
var r = route(state, events[i], cfg);
if (r.reject) {
rejections.push(r.reject);
if (policy === "halt") { halted = true; break; }
// skip: record the rejection, do not advance, continue to the next event.
continue;
}
state = r.state;
stream.push(r.emit);
}
return { state: state, stream: stream, rejections: rejections, halted: halted };
}
/* render(state) -> string. Pure state -> frame projection. An interactive gift
is still pipe-testable headless: the frame is deterministic text, never a live
canvas. */
function render(state) {
return (
"accepted=" + state.count +
" next-seq=" + state.nextSeq +
" last-tick=" + state.lastTick
);
}
var API = {
DEFAULT_ACCEPTED_TYPES: DEFAULT_ACCEPTED_TYPES,
ACCEPTED_TYPES: DEFAULT_ACCEPTED_TYPES,
state0: state0,
validate: validate,
route: route,
foldLog: foldLog,
render: render
};
// Browser attach.
if (typeof window !== "undefined") {
window.ForestGifts = window.ForestGifts || {};
window.ForestGifts.inputEventRouter = API;
}
// Node require.
if (typeof module !== "undefined" && module.exports) {
module.exports = API;
}
// CLI.
function main(argv) {
var args = argv.slice(2);
if (args.indexOf("--help") !== -1 || args.indexOf("-h") !== -1) {
process.stdout.write(
"input-event-router.js — deterministic reducer: raw input-event log -> validated typed stream.\n\n" +
" node input-event-router.js < events.jsonl fold a JSONL event log -> render + summary\n" +
" node input-event-router.js --help\n\n" +
"Envelope (one JSON object per line):\n" +
" { \"seq\": <int, monotonic +1>, \"t\": <int, non-decreasing logical tick>,\n" +
" \"type\": <accepted type>, \"payload\": <object> }\n\n" +
"Edge: this normalizes and validates input events against a declared schema,\n" +
"folding them into a deterministic typed stream. It does not sanitize\n" +
"application semantics, does not persist, holds no clock/network/entropy, and\n" +
"trusts no event it did not schema.\n"
);
return 0;
}
var chunks = [];
process.stdin.on("data", function (d) { chunks.push(d); });
process.stdin.on("end", function () {
var text = Buffer.concat(chunks).toString("utf8");
var lines = text.split("\n");
var events = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line.length === 0) continue;
try {
events.push(JSON.parse(line));
} catch (e) {
// A non-JSON line is itself a rejectable event: push a sentinel the
// schema will reject as event-not-object (a string is not an object).
events.push(line);
}
}
var out = foldLog(events);
process.stdout.write(render(out.state) + "\n");
process.stdout.write(
"emitted=" + out.stream.length +
" rejected=" + out.rejections.length +
(out.halted ? " HALTED" : "") + "\n"
);
for (var j = 0; j < out.rejections.length; j++) {
var rj = out.rejections[j];
process.stdout.write(" reject seq=" + rj.seq + " reason=" + rj.reason + "\n");
}
});
return 0;
}
if (typeof require !== "undefined" && require.main === module) {
process.exitCode = main(process.argv);
}
test_input-event-router.js146 lineson GitHub →
#!/usr/bin/env node
/* test_input-event-router.js — the golden event-log corpus.
The battery drift-checks the gift against an OUT-OF-BAND oracle: expected final
states and emitted streams authored independently (by hand, from the envelope
rules), not by running the gift. Plus replay-determinism (the canonicalizer
self-test), the mandatory hostile-event known-bad vectors (§3 security check),
a state-bounds assertion, and a mutation bite so a no-op harness cannot pass.
*/
"use strict";
var R = require("./input-event-router.js");
var assert = require("assert");
var pass = 0, fail = 0;
function ok(name, cond) {
if (cond) { pass++; }
else { fail++; console.error("FAIL: " + name); }
}
function eq(name, a, b) {
var ja = JSON.stringify(a), jb = JSON.stringify(b);
if (ja === jb) { pass++; }
else { fail++; console.error("FAIL: " + name + "\n got: " + ja + "\n want: " + jb); }
}
// ---------------------------------------------------------------------------
// 1. Golden valid log -> expected final state + emitted stream (out-of-band).
// Expected values hand-computed from the envelope rules, NOT from the gift.
// ---------------------------------------------------------------------------
var validLog = [
{ seq: 0, t: 0, type: "pointer", payload: { x: 10, y: 20 } },
{ seq: 1, t: 0, type: "select", payload: { region: "A1" } },
{ seq: 2, t: 3, type: "edit", payload: { cell: "A1", value: 7 } },
{ seq: 3, t: 3, type: "commit", payload: {} }
];
// Hand-authored oracle: 4 accepted, nextSeq ends at 4, lastTick is 3, count 4.
var expectedState = { nextSeq: 4, lastTick: 3, count: 4 };
// Emitted stream = the same four events, payloads copied field-for-field.
var expectedStream = [
{ seq: 0, t: 0, type: "pointer", payload: { x: 10, y: 20 } },
{ seq: 1, t: 0, type: "select", payload: { region: "A1" } },
{ seq: 2, t: 3, type: "edit", payload: { cell: "A1", value: 7 } },
{ seq: 3, t: 3, type: "commit", payload: {} }
];
var g = R.foldLog(validLog);
eq("golden final state", g.state, expectedState);
eq("golden emitted stream", g.stream, expectedStream);
ok("golden: no rejections", g.rejections.length === 0);
ok("golden: not halted", g.halted === false);
eq("golden render", R.render(g.state), "accepted=4 next-seq=4 last-tick=3");
// ---------------------------------------------------------------------------
// 2. Replay-determinism (the canonicalizer self-test) — fold N times ->
// byte-identical state, stream, render each time.
// ---------------------------------------------------------------------------
var r1 = R.foldLog(validLog);
var r2 = R.foldLog(validLog);
var r3 = R.foldLog(validLog);
eq("replay: state identical", JSON.stringify(r1.state), JSON.stringify(r3.state));
eq("replay: stream identical", JSON.stringify(r1.stream), JSON.stringify(r2.stream));
eq("replay: render identical", R.render(r1.state), R.render(r3.state));
// ---------------------------------------------------------------------------
// 3. Hostile-event known-bad vectors (§3.1) — each MUST produce its declared
// rejection reason, never a corruption/crash/coercion. Default policy=halt.
// ---------------------------------------------------------------------------
function firstReject(log) {
var out = R.foldLog(log);
return out.rejections.length ? out.rejections[0].reason : "(none)";
}
eq("hostile: seq gap -> seq-gap",
firstReject([{ seq: 0, t: 0, type: "key", payload: {} },
{ seq: 1, t: 0, type: "key", payload: {} },
{ seq: 3, t: 0, type: "key", payload: {} }]),
"seq-gap");
eq("hostile: seq repeat -> seq-repeat",
firstReject([{ seq: 0, t: 0, type: "key", payload: {} },
{ seq: 1, t: 0, type: "key", payload: {} },
{ seq: 1, t: 0, type: "key", payload: {} }]),
"seq-repeat");
eq("hostile: decreasing tick -> tick-decrease",
firstReject([{ seq: 0, t: 5, type: "key", payload: {} },
{ seq: 1, t: 2, type: "key", payload: {} }]),
"tick-decrease");
eq("hostile: unknown type -> type-not-accepted",
firstReject([{ seq: 0, t: 0, type: "no-such-type", payload: {} }]),
"type-not-accepted");
eq("hostile: scalar payload -> payload-not-object",
firstReject([{ seq: 0, t: 0, type: "key", payload: 42 }]),
"payload-not-object");
eq("hostile: null payload -> payload-not-object",
firstReject([{ seq: 0, t: 0, type: "key", payload: null }]),
"payload-not-object");
eq("hostile: array payload -> payload-not-object",
firstReject([{ seq: 0, t: 0, type: "key", payload: [1, 2] }]),
"payload-not-object");
eq("hostile: non-object event -> event-not-object",
firstReject(["this is not an object"]),
"event-not-object");
// 3b. Prototype-pollution known-bad twin: a __proto__ payload key must NOT
// pollute Object.prototype, and must be dropped from the emitted payload.
var poll = R.foldLog([{ seq: 0, t: 0, type: "edit",
payload: JSON.parse('{"__proto__":{"polluted":true},"safe":1}') }]);
ok("proto-pollution: Object.prototype not polluted", ({}).polluted === undefined);
ok("proto-pollution: emitted, not rejected", poll.stream.length === 1 && poll.rejections.length === 0);
eq("proto-pollution: dangerous key dropped, safe key kept",
poll.stream[0].payload, { safe: 1 });
// 3c. halt vs skip policy.
var halted = R.foldLog([{ seq: 0, t: 0, type: "key", payload: {} },
{ seq: 2, t: 0, type: "key", payload: {} }, // gap -> halt here
{ seq: 3, t: 0, type: "key", payload: {} }]);
ok("halt: stops at first violation", halted.halted === true && halted.stream.length === 1);
var skipped = R.foldLog([{ seq: 0, t: 0, type: "key", payload: {} },
{ seq: 2, t: 0, type: "key", payload: {} }, // gap, skipped
{ seq: 1, t: 0, type: "key", payload: {} }], // now valid (nextSeq still 1)
{ rejectPolicy: "skip" });
ok("skip: continues past a rejection", skipped.halted === false &&
skipped.stream.length === 2 && skipped.rejections.length === 1);
// ---------------------------------------------------------------------------
// 4. State-bounds (§3.2) — over a large synthetic log the state stays exactly
// three integers; no retained buffer. Assert structurally + by memory shape.
// ---------------------------------------------------------------------------
var N = 100000;
var big = [];
for (var i = 0; i < N; i++) big.push({ seq: i, t: i, type: "key", payload: {} });
var bigOut = R.foldLog(big);
eq("state-bounds: state keys are exactly {nextSeq,lastTick,count}",
Object.keys(bigOut.state).sort(), ["count", "lastTick", "nextSeq"]);
ok("state-bounds: count == N", bigOut.state.count === N);
ok("state-bounds: state carries no array/buffer field",
Object.keys(bigOut.state).every(function (k) { return typeof bigOut.state[k] === "number"; }));
// ---------------------------------------------------------------------------
// 5. Mutation bite — a deliberately-wrong expected state MUST be caught, so a
// no-op harness cannot pass green.
// ---------------------------------------------------------------------------
var wrongExpected = { nextSeq: 999, lastTick: 3, count: 4 };
ok("mutation bite: wrong expected state is detected",
JSON.stringify(g.state) !== JSON.stringify(wrongExpected));
// ---------------------------------------------------------------------------
console.log((fail === 0 ? "OK" : "FAILED") + " — " + pass + " passed, " + fail + " failed");
process.exitCode = fail === 0 ? 0 : 1;