From 75a4d8ff485ac472d0f8d1d49008408d0aab8525 Mon Sep 17 00:00:00 2001 From: Enrico Becker <90171652+Dewinator@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:58:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(swarm):=20wire-validator=20=E2=80=94=20rej?= =?UTF-8?q?ection=20rules=201-13=20from=20SWARM=5FSPEC=20=C2=A75=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the pure runtime validator every receive path will compose: validateWireRecord(record, kind, opts) returns { ok: true } or { ok: false, rule, reason }. No DB, no HTTP, no I/O — all side-channel data (clock, pubkey-by-node lookup) injected via opts so the function is deterministic and trivially testable. Covers rules 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13. Skips 10 (caller's job — needs DB read), 14 (local trust), and 15 (transport-layer body cap), per the issue's explicit out-of-scope list. Reuses computeNodeId from services/node-identity.ts and verify from services/signature.ts rather than re-implementing — re-implementation would create a divergence surface against the producer side, which is exactly the regression the swarm trust premise cannot tolerate. Test coverage: 1 valid example per kind + 12 negative tests (one per implemented rule), each starting from a fully-valid record and mutating one invariant so the asserted rule cannot mask another. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/wire-validator.test.ts | 419 ++++++++++++++++++ mcp-server/src/services/wire-validator.ts | 403 +++++++++++++++++ 2 files changed, 822 insertions(+) create mode 100644 mcp-server/src/__tests__/wire-validator.test.ts create mode 100644 mcp-server/src/services/wire-validator.ts diff --git a/mcp-server/src/__tests__/wire-validator.test.ts b/mcp-server/src/__tests__/wire-validator.test.ts new file mode 100644 index 0000000..1ae2a50 --- /dev/null +++ b/mcp-server/src/__tests__/wire-validator.test.ts @@ -0,0 +1,419 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; + +import { + validateWireRecord, + type ValidationResult, +} from "../services/wire-validator.js"; +import { sign } from "../services/signature.js"; +import { computeNodeId } from "../services/node-identity.js"; +import { WIRE_SPEC_VERSION } from "../services/wire-types.js"; + +// --------------------------------------------------------------------------- +// Test fixtures +// +// Every negative test starts from a fully-valid record and breaks ONE +// invariant. That keeps the assertion surface clean: if a test fails on +// rule N but really tripped over rule M, the helper would mask it; we +// build up the valid baseline once and mutate from there. +// --------------------------------------------------------------------------- + +interface Identity { + pem: string; + pubkeyRaw: Uint8Array; + nodeId: string; +} + +function freshIdentity(): Identity { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const spki = publicKey.export({ type: "spki", format: "der" }); + // Last 32 bytes of the SPKI DER encoding are the raw Ed25519 pubkey. + const pubkeyRaw = new Uint8Array(spki.subarray(spki.length - 32)); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + const nodeId = computeNodeId(Buffer.from(pubkeyRaw)); + return { pem, pubkeyRaw, nodeId }; +} + +function full768Embedding(seed = 0): number[] { + const out = new Array(768); + for (let i = 0; i < 768; i++) out[i] = (seed + i) / 1000; + return out; +} + +// Anchor "now" so rule 7/8 windows are deterministic. +const FIXED_NOW = new Date("2026-04-28T12:00:00.000Z"); + +function makePubkeyResolver(known: Map) { + return (nodeId: string): Uint8Array | null => known.get(nodeId) ?? null; +} + +function attachSignature( + record: Record, + pem: string +): Record { + return { ...record, signature: sign(record, pem) }; +} + +// --------------------------------------------------------------------------- +// Valid examples — one per kind +// --------------------------------------------------------------------------- + +function buildValidLesson(producer: Identity): Record { + const unsigned = { + id: "11111111-2222-3333-4444-555555555555", + content: "Lessons must be generalized before they leave a node.", + embedding: full768Embedding(), + synthesized_from_cluster_size: 4, + origin_node_id: producer.nodeId, + signed_at: "2026-04-28T11:00:00.000Z", + created_at: "2026-04-27T10:00:00.000Z", + tags: ["test", "swarm"], + spec_version: WIRE_SPEC_VERSION, + }; + return attachSignature(unsigned, producer.pem); +} + +function buildValidHubAnchor(producer: Identity): Record { + const unsigned = { + embedding: full768Embedding(7), + hub_score: 0.82, + local_memory_count: 17, + topic_label: "test-cluster", + origin_node_id: producer.nodeId, + signed_at: "2026-04-28T11:00:00.000Z", + spec_version: WIRE_SPEC_VERSION, + }; + return attachSignature(unsigned, producer.pem); +} + +function buildValidAdvertisement(self: Identity): Record { + // Pubkey on the wire is unpadded base64url per SWARM_SPEC §3.3. + const pubkeyB64Url = Buffer.from(self.pubkeyRaw).toString("base64url"); + const unsigned = { + node_id: self.nodeId, + pubkey: pubkeyB64Url, + display_name: "test-node", + endpoint_url: "https://node.example.com", + spec_version: WIRE_SPEC_VERSION, + signed_at: "2026-04-28T11:00:00.000Z", + }; + return attachSignature(unsigned, self.pem); +} + +function expectErr(result: ValidationResult, rule: number): void { + assert.equal(result.ok, false, `expected rejection on rule ${rule}, got ok=true`); + if (result.ok) return; // narrowing + assert.equal( + result.rule, + rule, + `expected rule ${rule}, got rule ${result.rule} (reason: ${result.reason})` + ); +} + +// --------------------------------------------------------------------------- +// Happy paths +// --------------------------------------------------------------------------- + +test("valid Lesson is accepted", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + assert.equal(result.ok, true); +}); + +test("valid HubAnchor is accepted", async () => { + const producer = freshIdentity(); + const record = buildValidHubAnchor(producer); + const result = await validateWireRecord(record, "hub_anchor", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + assert.equal(result.ok, true); +}); + +test("valid NodeAdvertisement is accepted (self-signed)", async () => { + const self = freshIdentity(); + const record = buildValidAdvertisement(self); + const result = await validateWireRecord(record, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + // Self-signed: getPubkeyForNode is not consulted; resolver returns null. + getPubkeyForNode: () => null, + }); + assert.equal(result.ok, true); +}); + +// --------------------------------------------------------------------------- +// Negative cases — one per rule (1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13) +// --------------------------------------------------------------------------- + +test("rule 1: spec_version major mismatch is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + // Re-sign with the wrong spec_version so rule 5 wouldn't also fire. + const { signature: _omit, ...unsigned } = valid; + const tampered = attachSignature( + { ...unsigned, spec_version: "2.0" }, + producer.pem + ); + const result = await validateWireRecord(tampered, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 1); +}); + +test("rule 2: missing required field is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer) as Record; + delete record.content; + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 2); +}); + +test("rule 3: type mismatch on a typed field is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + // synthesized_from_cluster_size is typed as number; flip to string. + const broken = { ...record, synthesized_from_cluster_size: "four" }; + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 3); +}); + +test("rule 4: embedding wrong length is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, embedding: [0.1, 0.2, 0.3] }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 4); +}); + +test("rule 4: embedding containing non-finite is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, embedding: _e, ...rest } = valid; + const emb = full768Embedding(); + emb[42] = Number.NaN; + // We cannot re-sign a record with NaN inside (canonicalize refuses); + // attach a placeholder signature that the validator will never reach. + const broken = { ...rest, embedding: emb, signature: "AAAA" }; + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 4); +}); + +test("rule 5: bad signature is rejected (content mutated post-sign)", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + // Mutate content AFTER signing so JCS-recompute produces different bytes. + const tampered = { ...valid, content: "Mutated after signing." }; + const result = await validateWireRecord(tampered, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 5); +}); + +test("rule 6: NodeAdvertisement node_id != multihash(pubkey) is rejected", async () => { + const self = freshIdentity(); + const other = freshIdentity(); + const valid = buildValidAdvertisement(self); + const { signature: _omit, ...unsigned } = valid; + // Substitute a foreign node_id and re-sign with self's key — rule 5 still + // verifies (self signed it) but rule 6 must trip first. + const broken = attachSignature( + { ...unsigned, node_id: other.nodeId }, + self.pem + ); + const result = await validateWireRecord(broken, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + expectErr(result, 6); +}); + +test("rule 7: signed_at more than 5 minutes in the future is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + // FIXED_NOW + 1 hour — well past the 5-min skew tolerance. + const broken = attachSignature( + { ...unsigned, signed_at: "2026-04-28T13:00:00.000Z" }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 7); +}); + +test("rule 8: Lesson signed_at older than 90 days is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + // 100 days before FIXED_NOW. created_at must precede signed_at (rule 9), + // so push it earlier too. + const broken = attachSignature( + { + ...unsigned, + signed_at: "2026-01-18T12:00:00.000Z", + created_at: "2026-01-17T12:00:00.000Z", + }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 8); +}); + +test("rule 9: Lesson signed_at < created_at is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { + ...unsigned, + signed_at: "2026-04-28T10:00:00.000Z", + created_at: "2026-04-28T11:00:00.000Z", + }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 9); +}); + +test("rule 11: Lesson synthesized_from_cluster_size < 2 is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, synthesized_from_cluster_size: 1 }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 11); +}); + +test("rule 12: Lesson.content over 8 KiB is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, content: "A".repeat(8 * 1024 + 1) }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 12); +}); + +test("rule 13: NodeAdvertisement endpoint_url not https is rejected", async () => { + const self = freshIdentity(); + const valid = buildValidAdvertisement(self); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, endpoint_url: "http://node.example.com" }, + self.pem + ); + const result = await validateWireRecord(broken, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + expectErr(result, 13); +}); + +// --------------------------------------------------------------------------- +// Defensive extras (not required by the issue, cheap to keep) +// --------------------------------------------------------------------------- + +test("non-object input is rejected (no exception thrown)", async () => { + const result = await validateWireRecord("not a record" as unknown, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + assert.equal(result.ok, false); +}); + +test("rule 5: unknown origin_node_id (no pubkey resolver hit) is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + // Resolver knows nobody — a swarm without trust state for this node. + getPubkeyForNode: () => null, + }); + expectErr(result, 5); +}); diff --git a/mcp-server/src/services/wire-validator.ts b/mcp-server/src/services/wire-validator.ts new file mode 100644 index 0000000..740eef9 --- /dev/null +++ b/mcp-server/src/services/wire-validator.ts @@ -0,0 +1,403 @@ +/** + * Wire validator — Swarm Phase 3b (issue #86). + * + * Pure runtime validator for incoming wire records. Implements rejection + * rules 1-13 from docs/SWARM_SPEC.md §5; rules 14 (local trust — caller's + * job) and 15 (body cap — transport-layer concern) are intentionally + * out of scope here. Rule 10 (duplicate id+signed_at with conflicting + * signature) is also caller-side: it requires a database read against + * already-ingested records and cannot be answered by a pure function. + * + * Design discipline (issue #86 Hard constraints): + * - Pure: no DB, no HTTP, no file I/O, no caching/memoization. + * - All side-channel data (current time, pubkey-by-node lookup) is + * injected via `opts` so this function is trivially testable and + * deterministic for a given input. + * - Composes the existing JCS+Ed25519 primitives in `signature.ts` and + * the `computeNodeId` helper in `node-identity.ts`. Re-implementing + * either would create a divergence surface — the trust premise of + * the swarm is that two honest implementations agree on the + * bytes-under-signature and on the node_id derivation. + */ +import { verify } from "./signature.js"; +import { computeNodeId } from "./node-identity.js"; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +export type ValidationOk = { ok: true }; +export type ValidationErr = { ok: false; rule: number; reason: string }; +export type ValidationResult = ValidationOk | ValidationErr; + +export type WireKind = "lesson" | "hub_anchor" | "node_advertisement"; + +export interface ValidateOptions { + /** Major component of the receiver's spec version (e.g. 1 for v1.x). */ + ourSpecMajor: number; + /** Current time. Injected so tests are clock-independent. */ + now: Date; + /** + * Lookup the raw 32-byte Ed25519 pubkey for a non-self node, or `null` + * if unknown. Used for rule 5 on Lesson and HubAnchor (signature + * verification against the producer's key). NodeAdvertisement is + * self-signed: the pubkey is in the record itself, so this callback + * is not consulted for that kind. + */ + getPubkeyForNode: (nodeId: string) => Uint8Array | null; +} + +// --------------------------------------------------------------------------- +// Constants from SWARM_SPEC §5 +// --------------------------------------------------------------------------- + +const FUTURE_TOLERANCE_MS = 5 * 60 * 1000; // rule 7 +const STALE_LIMIT_MS = 90 * 24 * 60 * 60 * 1000; // rule 8 +const LESSON_CONTENT_BYTE_LIMIT = 8 * 1024; // rule 12 +const HUB_TOPIC_LABEL_CHAR_LIMIT = 256; // rule 12 +const ADV_DISPLAY_NAME_CHAR_LIMIT = 64; // rule 12 +const EMBEDDING_DIM = 768; // rule 4 +const ED25519_PUBKEY_LEN = 32; + +// --------------------------------------------------------------------------- +// Field schemas — exhaustive required-field lists per kind +// +// The required-field set is the chokepoint for rules 2 and 3: a field +// that's missing is rule 2, a field present-but-wrong-type is rule 3. +// Optional fields (Lesson.tags, HubAnchor.topic_label, +// NodeAdvertisement.display_name) are validated separately because their +// rejection rules differ (rule 12 size limits, not rule 2/3 presence). +// --------------------------------------------------------------------------- + +type FieldType = "string" | "number" | "array"; + +interface FieldSpec { + name: string; + type: FieldType; +} + +const LESSON_REQUIRED: readonly FieldSpec[] = [ + { name: "id", type: "string" }, + { name: "content", type: "string" }, + { name: "embedding", type: "array" }, + { name: "synthesized_from_cluster_size", type: "number" }, + { name: "origin_node_id", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, + { name: "created_at", type: "string" }, + { name: "spec_version", type: "string" }, +]; + +const HUB_REQUIRED: readonly FieldSpec[] = [ + { name: "embedding", type: "array" }, + { name: "hub_score", type: "number" }, + { name: "local_memory_count", type: "number" }, + { name: "origin_node_id", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, + { name: "spec_version", type: "string" }, +]; + +const ADV_REQUIRED: readonly FieldSpec[] = [ + { name: "node_id", type: "string" }, + { name: "pubkey", type: "string" }, + { name: "endpoint_url", type: "string" }, + { name: "spec_version", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fail(rule: number, reason: string): ValidationErr { + return { ok: false, rule, reason }; +} + +function isObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function checkPresentAndTyped( + record: Record, + specs: readonly FieldSpec[] +): ValidationErr | null { + for (const f of specs) { + if (!(f.name in record) || record[f.name] === null || record[f.name] === undefined) { + return fail(2, `missing required field: ${f.name}`); + } + } + for (const f of specs) { + const v = record[f.name]; + if (f.type === "string" && typeof v !== "string") { + return fail(3, `field ${f.name} must be string, got ${typeof v}`); + } + if (f.type === "number" && (typeof v !== "number" || !Number.isFinite(v))) { + return fail( + 3, + `field ${f.name} must be a finite number, got ${typeof v}` + ); + } + if (f.type === "array" && !Array.isArray(v)) { + return fail(3, `field ${f.name} must be array, got ${typeof v}`); + } + } + return null; +} + +function checkEmbedding(value: unknown): ValidationErr | null { + if (!Array.isArray(value)) { + // Defensive — type pass already enforced array, this is belt-and-suspenders. + return fail(4, "embedding must be an array"); + } + if (value.length !== EMBEDDING_DIM) { + return fail( + 4, + `embedding must have ${EMBEDDING_DIM} elements, got ${value.length}` + ); + } + for (let i = 0; i < value.length; i++) { + const x = value[i]; + if (typeof x !== "number" || !Number.isFinite(x)) { + return fail(4, `embedding[${i}] is not a finite number`); + } + } + return null; +} + +/** Parse `.` per SWARM_SPEC §1. Returns null if malformed. */ +function parseSpecMajor(specVersion: string): number | null { + // Spec §1: decimal integers, no leading zeros, no whitespace. Single-digit + // majors are common ("1.0"), multi-digit minors are valid ("1.10"); we + // accept any non-leading-zero decimal pair. + const m = specVersion.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + if (!m) return null; + return Number(m[1]); +} + +/** Parse an ISO-8601 timestamp into ms-epoch, or null if unparseable. */ +function parseTimestamp(s: string): number | null { + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +} + +/** + * Decode `pubkey` from a NodeAdvertisement. SWARM_SPEC §3 / §3.3 fix the + * encoding to unpadded base64url. We refuse padded base64 and any + * non-alphabet character so a producer that picked the wrong variant + * fails loudly here, not later inside Ed25519. + */ +function decodeAdvertisementPubkey(s: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]+$/.test(s)) return null; + const buf = Buffer.from(s, "base64url"); + return new Uint8Array(buf); +} + +// --------------------------------------------------------------------------- +// validateWireRecord — entry point +// +// Returns Promise per the issue contract; the body is +// synchronous today, but keeping the async surface lets a future receive +// path block on e.g. an async pubkey lookup without re-fitting every +// caller. (Synchronous in v1, async-shaped for v2 — same trade-off the +// rest of the swarm services took.) +// --------------------------------------------------------------------------- + +export async function validateWireRecord( + record: unknown, + kind: WireKind, + opts: ValidateOptions +): Promise { + if (!isObject(record)) { + return fail( + 3, + `record must be a JSON object, got ${ + Array.isArray(record) ? "array" : typeof record + }` + ); + } + + const specs = + kind === "lesson" + ? LESSON_REQUIRED + : kind === "hub_anchor" + ? HUB_REQUIRED + : ADV_REQUIRED; + + // Rules 2 + 3: required-field presence and JSON-type match. + const presenceErr = checkPresentAndTyped(record, specs); + if (presenceErr) return presenceErr; + + // Rule 1: spec_version major. Done after presence/type so we know the + // field is at least a string before we try to parse it. + const specVersion = record.spec_version as string; + const major = parseSpecMajor(specVersion); + if (major === null) { + // A string that isn't a valid . is a type-shape problem + // for that field — it can't pass spec-version negotiation but it isn't + // a major mismatch in the rule-1 sense either. Surface as rule 3. + return fail(3, `spec_version "${specVersion}" is not a valid .`); + } + if (major !== opts.ourSpecMajor) { + return fail( + 1, + `spec_version major ${major} != receiver's major ${opts.ourSpecMajor}` + ); + } + + // Rule 4: embedding shape (Lesson and HubAnchor only). + if (kind === "lesson" || kind === "hub_anchor") { + const e = checkEmbedding(record.embedding); + if (e) return e; + } + + // Rule 12: kind-specific size limits. Optional fields are also typed + // here because their type was not exercised by checkPresentAndTyped. + if (kind === "lesson") { + const content = record.content as string; + if (Buffer.byteLength(content, "utf8") > LESSON_CONTENT_BYTE_LIMIT) { + return fail( + 12, + `Lesson.content > ${LESSON_CONTENT_BYTE_LIMIT} bytes UTF-8` + ); + } + } else if (kind === "hub_anchor") { + const topic = record.topic_label; + if (topic !== undefined && topic !== null) { + if (typeof topic !== "string") { + return fail(3, `topic_label must be string, got ${typeof topic}`); + } + if (topic.length > HUB_TOPIC_LABEL_CHAR_LIMIT) { + return fail( + 12, + `HubAnchor.topic_label > ${HUB_TOPIC_LABEL_CHAR_LIMIT} chars` + ); + } + } + } else if (kind === "node_advertisement") { + const dn = record.display_name; + if (dn !== undefined && dn !== null) { + if (typeof dn !== "string") { + return fail(3, `display_name must be string, got ${typeof dn}`); + } + if (dn.length > ADV_DISPLAY_NAME_CHAR_LIMIT) { + return fail( + 12, + `NodeAdvertisement.display_name > ${ADV_DISPLAY_NAME_CHAR_LIMIT} chars` + ); + } + } + } + + // Rule 13: NodeAdvertisement.endpoint_url MUST be https. + if (kind === "node_advertisement") { + const url = record.endpoint_url as string; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return fail(13, `endpoint_url is not a valid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + return fail( + 13, + `endpoint_url must use https, got ${parsed.protocol}//` + ); + } + } + + // Rule 11: Lesson.synthesized_from_cluster_size >= 2 (Generalization rule + // enforced on the wire, SWARM_SPEC §3.1). + if (kind === "lesson") { + const n = record.synthesized_from_cluster_size as number; + if (n < 2) { + return fail( + 11, + `Lesson.synthesized_from_cluster_size must be >= 2, got ${n}` + ); + } + } + + // Rule 7 + 8: timestamp bounds. signed_at is required for all kinds; + // rule 8 (90-day staleness) is Lesson/HubAnchor only — NodeAdvertisement + // is expected to be re-fetched live. + const signedAtMs = parseTimestamp(record.signed_at as string); + if (signedAtMs === null) { + return fail(3, `signed_at is not a parseable ISO-8601 timestamp`); + } + const nowMs = opts.now.getTime(); + if (signedAtMs > nowMs + FUTURE_TOLERANCE_MS) { + return fail( + 7, + `signed_at ${record.signed_at} is more than 5 minutes in the future` + ); + } + if (kind === "lesson" || kind === "hub_anchor") { + if (signedAtMs < nowMs - STALE_LIMIT_MS) { + return fail( + 8, + `signed_at ${record.signed_at} is older than 90 days` + ); + } + } + + // Rule 9: Lesson.signed_at MUST NOT precede created_at — a producer + // cannot sign a lesson before having synthesized it. + if (kind === "lesson") { + const createdAtMs = parseTimestamp(record.created_at as string); + if (createdAtMs === null) { + return fail(3, `created_at is not a parseable ISO-8601 timestamp`); + } + if (signedAtMs < createdAtMs) { + return fail(9, `Lesson.signed_at (${record.signed_at}) < created_at (${record.created_at})`); + } + } + + // Rule 6: NodeAdvertisement.node_id MUST equal multihash(pubkey). This is + // also where we obtain the verifier pubkey for rule 5 — for self-signed + // advertisements, no out-of-band lookup is needed. + let verifierPubkey: Uint8Array | null; + if (kind === "node_advertisement") { + const pubkeyB64Url = record.pubkey as string; + const pubkeyBytes = decodeAdvertisementPubkey(pubkeyB64Url); + if (!pubkeyBytes || pubkeyBytes.length !== ED25519_PUBKEY_LEN) { + return fail( + 6, + `pubkey is not a 32-byte unpadded base64url Ed25519 key` + ); + } + let derivedNodeId: string; + try { + derivedNodeId = computeNodeId(Buffer.from(pubkeyBytes)); + } catch (e) { + return fail(6, `cannot derive node_id from pubkey: ${(e as Error).message}`); + } + if (derivedNodeId !== record.node_id) { + return fail( + 6, + `node_id ${record.node_id} != multihash(pubkey) ${derivedNodeId}` + ); + } + verifierPubkey = pubkeyBytes; + } else { + const originId = record.origin_node_id as string; + verifierPubkey = opts.getPubkeyForNode(originId); + if (!verifierPubkey) { + // We can't verify a signature against a key we don't hold — collapse + // this to a rule 5 rejection. Trust establishment (how the receiver + // ever obtains the pubkey for `originId`) is rule 14's territory. + return fail(5, `no pubkey available for origin_node_id ${originId}`); + } + } + + // Rule 5: Ed25519 signature verification over JCS-canonical bytes. + // `verify` strips the on-wire `signature` field internally before + // recomputing — see signature.ts. + const sig = record.signature as string; + if (!verify(record, sig, verifierPubkey)) { + return fail(5, `Ed25519 signature verification failed`); + } + + return { ok: true }; +}