diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index c0dd116..d42272d 100644 --- a/apps/component-test-app/package.json +++ b/apps/component-test-app/package.json @@ -16,7 +16,7 @@ "@automerge/automerge-subduction": "0.16.1", "@inkandswitch/onomancy-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", - "@inkandswitch/onomancy": "0.2.0", + "@inkandswitch/onomancy": "0.3.0", "@keyhive/keyhive": "0.1.0-alpha.8", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/apps/component-test-app/src/App.tsx b/apps/component-test-app/src/App.tsx index 1df98b7..411f630 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -6,6 +6,7 @@ import { useSyncExternalStore, } from "react"; import { + ImmutableString, isValidAutomergeUrl, useDocument, type AutomergeUrl, @@ -20,6 +21,7 @@ import { bytesToHex, CopyableField, createDocumentTarget, + bindEdge, createGroupTarget, DirectoryProvider, type DirectoryDoc, @@ -36,16 +38,14 @@ import { useOnomancyDirectory, type DnsDesignation, } from "@inkandswitch/onomancy-react/onomancy"; -import { composeDirectories } from "./composeDirectories"; + import { DocumentPanel, LoadDocument } from "./DocumentPanel"; import { - checkSegments, hostnameRoot, parseLookup, resolveLookup, type Resolution, } from "./nameResolution"; -import { createLocalDirectory } from "./localDirectory"; import { createStubOnomancy } from "./onomancyStub"; import { keyhiveRuntime } from "./keyhiveRuntime"; @@ -75,9 +75,6 @@ interface AppProps { * A test app for the onomancy-react components. */ export default function App({ hive, repo }: AppProps) { - // The localStorage copy: always available, never shared. - const localDirectory = useMemo(() => createLocalDirectory(), []); - // The shared directory document: the root doc a domain can bind (its id // goes in the TXT record's p= field), created on first run or loaded from // another profile. @@ -89,11 +86,13 @@ export default function App({ hive, repo }: AppProps) { if (directoryUrl) return; let cancelled = false; void (async () => { - // Seeded with the reserved namestore map: a completely empty initial - // document never reaches the ready state in the current stack. - const handle = await repo.create2< - DirectoryDoc & { onomancy?: Record } - >({ onomancy: {} }); + // Seeded with an empty certificate list: a completely empty initial + // document never reaches the ready state in the current stack, and + // the list is the flat layout's own protocol key — a non-reference + // value, absent from name matching and from the directory's entries. + const handle = await repo.create2>({ + ".well-known/onomancy/certificates": [], + }); await hive.addSyncServerRelayToDoc(handle.url); if (!cancelled) { localStorage.setItem(DIRECTORY_URL_KEY, handle.url); @@ -155,14 +154,12 @@ export default function App({ hive, repo }: AppProps) { } ); - // Reads prefer the shared document, writes go to both. - const directory: NameDirectory = useMemo( - () => - directoryDoc - ? composeDirectories(docDirectory, localDirectory) - : localDirectory, - [directoryDoc, docDirectory, localDirectory] - ); + // The shared document is the only name store: its local Automerge replica + // is already the offline copy, so a second cache would only shadow it. + // Before the document is ready, the directory reads empty and reports + // non-writable, and the profile editor says so — an honest window, where + // a fallback accepted writes nobody else would ever see. + const directory: NameDirectory = docDirectory; // A real app imports @inkandswitch/onomancy here instead of the stub. const onomancyRuntime = useMemo( @@ -188,17 +185,19 @@ export default function App({ hive, repo }: AppProps) { designation, }); - // Namestore edges: path keys mapping to bare automerge: references under - // the reserved key, per the path-resolution spec's namestore layout. - // The bind path's anchor selects WHICH namestore the edge is written into: - // `~`/bare into our own directory, `@hostname` into whatever root document - // the domain designates, `automerge:` into that document directly. Once the - // anchor picks the document, the write is identical — anchors only decide - // where a walk (or a bind) starts. + // Namestore edges: path keys mapping to bare automerge: references in the + // document's own top-level map, per the path-resolution spec's namestore + // layout (flat, multi-segment keys, shared with protocol and directory + // data). The bind path's anchor selects WHICH namestore the edge is + // written into: `~`/bare into our own directory, `@hostname` into whatever + // root document the domain designates, `automerge:` into that document + // directly. Once the anchor picks the document, the write is identical — + // anchors only decide where a walk (or a bind) starts. const bindName = useCallback( async (rawPath: string, url: AutomergeUrl): Promise => { + // Segment hygiene comes with the parse: `parseLookup` goes through + // the onomancy grammar, so what is bound is exactly what resolves. const { root, segments } = parseLookup(rawPath); - checkSegments(segments); if (segments.length === 0) { throw new Error("Nothing to bind: add at least one path segment."); } @@ -220,17 +219,19 @@ export default function App({ hive, repo }: AppProps) { let handle; try { - handle = await repo.find< - DirectoryDoc & { onomancy?: Record } - >(targetUrl); + handle = await repo.find>(targetUrl); } catch { throw new Error( `The target namestore is not available locally and could not be fetched from the sync server: ${targetUrl}` ); } handle.change((doc) => { - doc.onomancy ??= {}; - doc.onomancy[key] = url; + // The library helper carries the layout rules — reserved-path + // refusal, the flat top-level write, the legacy-container cleanup. + // The scalar-string encoding is ours to inject because it is the + // substrate's: a plain JS string assigned into an Automerge map + // becomes `Text`, which a conforming reader refuses. + bindEdge(doc, key, url, (target) => new ImmutableString(target)); }); return spelling; }, diff --git a/apps/component-test-app/src/localDirectory.ts b/apps/component-test-app/src/localDirectory.ts deleted file mode 100644 index e12f9cd..0000000 --- a/apps/component-test-app/src/localDirectory.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - DirectoryEntry, - DirectoryEntryKind, - NameDirectory, -} from "@inkandswitch/onomancy-react"; - -/** - * A name directory kept in localStorage. Its contents live outside React so it - * notifies through `subscribe`. - */ - -const STORAGE_KEY = "keyhive-test-app-directory"; - -/** Avatars are bytes and localStorage holds strings, so this uses base64. */ -interface StoredEntry { - name?: string; - peerId?: string; - avatarBase64?: string; - kind?: DirectoryEntryKind; - contactCard?: string; - dnsName?: string; -} - -type StoredDirectory = Record; - -function read(): StoredDirectory { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as StoredDirectory) : {}; - } catch { - return {}; - } -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -function base64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -export function createLocalDirectory(): NameDirectory { - const listeners = new Set<() => void>(); - let stored = read(); - - // Keyed by the base64 it came from, so lookup returns a stable reference. - const avatarCache = new Map(); - - function decodeAvatar(base64: string | undefined): Uint8Array | null { - if (!base64) return null; - const cached = avatarCache.get(base64); - if (cached) return cached; - const bytes = base64ToBytes(base64); - avatarCache.set(base64, bytes); - return bytes; - } - - function toEntry(id: string, record: StoredEntry): DirectoryEntry { - return { - id, - name: record.name, - peerId: record.peerId, - avatar: decodeAvatar(record.avatarBase64), - kind: record.kind, - contactCard: record.contactCard, - dnsName: record.dnsName, - }; - } - - function notify() { - for (const listener of listeners) listener(); - } - - // Does not fire in the tab that made the change, so publish notifies too. - function onStorage(event: StorageEvent) { - if (event.key !== STORAGE_KEY) return; - stored = read(); - notify(); - } - - return { - source: "localStorage", - trust: "unverified", - writable: true, - enumerable: true, - notice: - "Names are stored in this browser only. Nothing is shared, and nothing is verified.", - - lookup(id) { - const record = stored[id]; - return record ? toEntry(id, record) : undefined; - }, - - list() { - return Object.entries(stored).map(([id, record]) => toEntry(id, record)); - }, - - publish(entry) { - const existing = stored[entry.id] ?? {}; - const record: StoredEntry = { ...existing }; - if (entry.name !== undefined) record.name = entry.name; - if (entry.peerId !== undefined) record.peerId = entry.peerId; - if (entry.avatar !== undefined) { - record.avatarBase64 = entry.avatar - ? bytesToBase64(entry.avatar) - : undefined; - } - if (entry.kind !== undefined) record.kind = entry.kind; - if (entry.contactCard !== undefined) - record.contactCard = entry.contactCard; - // The empty string clears a claim; undefined leaves it alone. - if (entry.dnsName !== undefined) { - if (entry.dnsName === "") delete record.dnsName; - else record.dnsName = entry.dnsName; - } - stored = { ...stored, [entry.id]: record }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); - notify(); - }, - - // The window listener is attached only while someone is subscribed, so a - // directory that is built and dropped leaves nothing behind. - subscribe(listener) { - listeners.add(listener); - if (listeners.size === 1) { - window.addEventListener("storage", onStorage); - // Another tab may have written while nothing was listening. - stored = read(); - notify(); - } - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - window.removeEventListener("storage", onStorage); - } - }; - }, - }; -} diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts index 46469ad..3244260 100644 --- a/apps/component-test-app/src/nameResolution.ts +++ b/apps/component-test-app/src/nameResolution.ts @@ -1,9 +1,11 @@ import { + isImmutableString, isValidAutomergeUrl, stringifyAutomergeUrl, type AutomergeUrl, type Repo, } from "@automerge/react/slim"; +import { Name } from "@inkandswitch/onomancy"; import { hexToBytes, RESERVED_ONOMANCY_KEY, @@ -12,10 +14,9 @@ import type { OnomancyRuntime } from "@inkandswitch/onomancy-react/onomancy"; /** * The path-resolution walk over locally held documents, per the onomancy - * path-resolution spec: greedy longest-key matching against the flat - * namestore map under the reserved key, one hop per matched edge, no - * backtracking. Partial outcomes are the designed norm under partition, - * not errors. + * path-resolution spec: greedy longest-key matching against the document's + * own flat top-level map, one hop per matched edge, no backtracking. + * Partial outcomes are the designed norm under partition, not errors. */ export type Resolution = @@ -43,29 +44,41 @@ export interface ParsedLookup { * the walk after it is identical. */ export function parseLookup(raw: string): ParsedLookup { - let rest = raw.trim(); - const root: ParsedLookup["root"] = "self"; + const trimmed = raw.trim(); + if (trimmed === "") return { root: "self", segments: [] }; - if (rest.startsWith("@")) { - const [hostname, ...segments] = rest.slice(1).split("/"); - if (!hostname || !hostname.includes(".")) { - throw new Error(`Not a DNS name: "@${hostname}"`); - } - return { root: { hostname: hostname.toLowerCase() }, segments }; - } + // A sigil-less string is read as a local name, as a typing convenience; + // everything after that convenience is the grammar's. Parsing through + // onomancy's own `Name` — the same code that decides names everywhere + // else — is what keeps this app from drifting: canonicalization, dotless + // names, IP literals, label and segment rules all come from one place. + // (The stub never fakes the grammar either; only resolution is faked.) + const spelled = + trimmed.startsWith("~") || + trimmed.startsWith("@") || + trimmed.startsWith("automerge:") + ? trimmed + : `~/${trimmed}`; - if (rest.startsWith("automerge:")) { - const [anchor, ...segments] = rest.split("/"); - if (!isValidAutomergeUrl(anchor)) { - throw new Error(`Not a document anchor: "${anchor}"`); + const name = new Name(spelled); + try { + const segments = [...name.segments]; + switch (name.anchorKind) { + case "local": + return { root: "self", segments }; + case "dns": + // Printed with its sigil; the hostname is what DNS is asked about. + return { root: { hostname: name.anchor.slice(1) }, segments }; + case "doc": + return { root: { url: name.anchor as AutomergeUrl }, segments }; + default: + // The grammar has exactly three anchor kinds. A fourth means this + // app and its onomancy disagree about the name grammar. + throw new Error(`Unknown anchor kind: "${name.anchorKind}"`); } - return { root: { url: anchor }, segments }; + } finally { + name.free(); } - - if (rest === "~") return { root, segments: [] }; - if (rest.startsWith("~/")) rest = rest.slice(2); - if (rest === "") return { root, segments: [] }; - return { root, segments: rest.split("/") }; } /** The Automerge URL for a hex-encoded 32-byte document id. */ @@ -75,19 +88,6 @@ export function urlFromDocIdHex(hex: string): AutomergeUrl { ); } -/** Segment hygiene per the name grammar: reject rather than normalize. */ -export function checkSegments(segments: string[]): void { - for (const segment of segments) { - if (segment === "") throw new Error("Empty segment."); - if (segment === "." || segment === "..") { - throw new Error("No traversal segments."); - } - if (/[#/\p{Cc}]/u.test(segment)) { - throw new Error(`Invalid segment: "${segment}"`); - } - } -} - /** The namestore edges of one held document, malformed values absent. */ async function namestoreOf( repo: Repo, @@ -101,20 +101,63 @@ async function namestoreOf( return undefined; } if (typeof doc !== "object" || doc === null) return undefined; - const map = (doc as Record)[RESERVED_ONOMANCY_KEY]; - if (typeof map !== "object" || map === null) return {}; + // The document's own top-level map IS the namestore (flat layout). Bare + // references only: anything else is absent (E5) — which is also what + // keeps directory entries, certificate lists, and other protocol data + // out of the walk without any key registry — and malformed keys never + // match already-valid segments (E6). const edges: Record = {}; - for (const [key, value] of Object.entries(map)) { - // Bare references only: anything else is absent (E5), and malformed - // keys never match already-valid segments (E6). - if (typeof value === "string" && isValidAutomergeUrl(value)) { - edges[key] = value; + for (const [key, value] of Object.entries(doc)) { + const target = edgeUrlOf(value); + if (target !== undefined) edges[key] = target; + } + + // The legacy nested layout, resolved as a fallback during the migration + // window: a flat edge shadows a nested one at the same path, rebinding + // migrates a path for free, and the branch's removal condition is that + // no namestore in use still holds a nested edge — the log keeps such + // documents visible. + const legacy = (doc as Record)[RESERVED_ONOMANCY_KEY]; + if (typeof legacy === "object" && legacy !== null) { + const inherited: string[] = []; + for (const [key, value] of Object.entries(legacy)) { + const target = edgeUrlOf(value); + if (target !== undefined && !(key in edges)) { + edges[key] = target; + inherited.push(key); + } + } + if (inherited.length > 0) { + console.warn( + `onomancy: ${url} still resolves ${inherited.length} edge(s) from the legacy nested layout (${inherited.join( + ", " + )}); rebind them or migrate — conforming resolvers cannot see them` + ); } } return edges; } +/** + * The reference a namestore value carries, or `undefined` when the value + * is not one (E5/E8). + * + * Scalar strings (`ImmutableString`, the only encoding a conforming + * reader matches) and plain JS strings from legacy data. This leniency is + * bounded by behaviour rather than structure (neither app splices edge + * values); it keeps old edges resolving through the migration window + * and shares the legacy branch's removal condition. + */ +function edgeUrlOf(value: unknown): AutomergeUrl | undefined { + const text = isImmutableString(value) + ? value.val + : typeof value === "string" + ? value + : undefined; + return text !== undefined && isValidAutomergeUrl(text) ? text : undefined; +} + /** Greedy longest-key match: the most segments, at segment boundaries. */ function longestMatch( edges: Record, @@ -132,14 +175,16 @@ function longestMatch( return best; } -/** Resolve segments from a root document. No backtracking, live reads. */ +/** + * Resolve segments from a root document. No backtracking, live reads. + * Segment hygiene is `parseLookup`'s (the grammar's): every caller's + * segments come out of a parsed name. + */ export async function resolvePath( repo: Repo, root: AutomergeUrl, segments: string[] ): Promise { - checkSegments(segments); - let current = root; let consumed = 0; const total = segments.length; diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts index c44326c..956d96e 100644 --- a/apps/component-test-app/src/onomancyStub.ts +++ b/apps/component-test-app/src/onomancyStub.ts @@ -15,8 +15,13 @@ import type { OnomancyModule } from "@inkandswitch/onomancy-react/onomancy"; export function createStubOnomancy(selfIdHex: string): OnomancyModule { return { // The grammar is never stubbed: `.test` hostnames are ordinary DNS - // names, so parsing them is the real parser's job either way. + // names, so parsing them is the real parser's job either way. The same + // goes for the RRset rules and the anchor decoder — the fabricated + // `.test` records are grammatical, so the real classifier judges them, + // and only resolution itself is ever faked. Name: onomancy.Name, + classifyRecords: onomancy.classifyRecords, + docAnchorBytes: onomancy.docAnchorBytes, resolveHostname(hostname: string, dohUrl?: string | null) { if (!hostname.endsWith(".test")) { @@ -26,7 +31,12 @@ export function createStubOnomancy(selfIdHex: string): OnomancyModule { case "self.test": return Promise.resolve(outcome(hostname, selfIdHex)); case "other.test": - return Promise.resolve(outcome(hostname, "ab".repeat(32))); + // A valid curve point that is not the local identity: fill(0x03) + // is in the conformance vectors' point-validity table. fill bytes + // are NOT points in general (~half the byte space is not), and a + // non-point p= makes the whole record malformed — which reads as + // "publishes no usable record", not as a mismatch. + return Promise.resolve(outcome(hostname, "03".repeat(32))); default: // A plain Error with no `reason` property: the directory's // conservative fallback maps it to `offline`, and the e2e diff --git a/package.json b/package.json index 8edbec8..63545db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inkandswitch/onomancy-react", - "version": "0.1.0", + "version": "0.2.0", "description": "React components and hooks for keyhive access control.", "license": "MIT", "repository": { @@ -70,11 +70,18 @@ "peerDependencies": { "@automerge/automerge-repo-keyhive": ">=0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": ">=0.3.0", "react": "^18.3.1" }, + "peerDependenciesMeta": { + "@inkandswitch/onomancy": { + "optional": true + } + }, "devDependencies": { "@automerge/automerge-repo-keyhive": "0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": "0.3.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.39.5", "@playwright/test": "1.61.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 509c03e..ccb40a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@eslint/js': specifier: ^9.39.5 version: 9.39.5 + '@inkandswitch/onomancy': + specifier: 0.3.0 + version: 0.3.0 '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -82,8 +85,8 @@ importers: specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@inkandswitch/onomancy': - specifier: 0.2.0 - version: 0.2.0 + specifier: 0.3.0 + version: 0.3.0 '@inkandswitch/onomancy-react': specifier: workspace:* version: link:../.. @@ -477,8 +480,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inkandswitch/onomancy@0.2.0': - resolution: {integrity: sha512-keh5i80jwtoCK6+6w1vwvGpvskhlZW5XF2OyjxRLsvPFCFpqmPTtZ5c1h9SGReiZNoh3icGpdZ2wG0fuLNx8mQ==} + '@inkandswitch/onomancy@0.3.0': + resolution: {integrity: sha512-rl5PUDRenAquVlQWO3Wjjj18l15GdcXpo0irpypeWIKn9VD1xhgFFOVtL/D9KFzTtFKxuc1rSwXI7wa2klE3Nw==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -504,7 +507,6 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] - libc: [glibc] '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} @@ -568,79 +570,66 @@ packages: resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.4': resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.4': resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.4': resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.4': resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.4': resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.4': resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.4': resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.4': resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.4': resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.4': resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.4': resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.4': resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.4': resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} @@ -1960,7 +1949,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inkandswitch/onomancy@0.2.0': {} + '@inkandswitch/onomancy@0.3.0': {} '@jridgewell/gen-mapping@0.3.13': dependencies: diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index 4ce7f0e..5f5474a 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -47,6 +47,42 @@ export interface AutomergeDocDirectoryOptions { const DEFAULT_NOTICE = "Names come from a shared document that anyone with its id can edit. They are not verified."; +/** + * Whether a top-level key may name a directory entry. + * + * The flat namestore layout puts names, protocol data, and directory + * entries in one shared top-level map, so what is NOT an entry id is + * decided here once for the read paths and the publish guard together: + * the `.well-known/` prefix (a writers' convention with asserted owners), + * and the legacy `onomancy` container during its migration window. + */ +function isEntryId(id: string): boolean { + return ( + id !== RESERVED_ONOMANCY_KEY && + id !== ".well-known" && + !id.startsWith(".well-known/") + ); +} + +/** + * Whether a top-level value is a directory entry, by shape. + * + * The same shared map holds namestore edges (scalar-string wrappers, + * which arrive as objects carrying their text in `val`), certificate + * lists (arrays), and byte payloads — none of which is an entry, and a + * spread of one would mint a junk row. Shape, not key: names are bare + * keys, so no key list can enumerate what to skip. + */ +function isEntryRecord(value: unknown): value is DirectoryDoc[string] { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + !("val" in value) + ); +} + /** * A directory backed by a single Automerge document, where each peer writes its * own entry. Build it with `useAutomergeDocDirectory`. @@ -65,31 +101,38 @@ export function createAutomergeDocDirectory( notice: options.notice ?? DEFAULT_NOTICE, lookup(id) { - if (id === RESERVED_ONOMANCY_KEY) return undefined; + if (!isEntryId(id)) return undefined; const record = doc?.[id]; - return record ? { id, ...record } : undefined; + return isEntryRecord(record) ? { id, ...record } : undefined; }, list() { if (!doc) return []; return Object.entries(doc) - .filter(([id]) => id !== RESERVED_ONOMANCY_KEY) + .filter( + (pair): pair is [string, DirectoryDoc[string]] => + isEntryId(pair[0]) && isEntryRecord(pair[1]) + ) .map(([id, record]) => ({ id, ...record })); }, }; if (change) { directory.publish = (entry: DirectoryEntry) => { - // Refused loudly. `lookup` and `list` filter this key, so an unguarded - // write succeeds and then becomes unreadable — and it lands in the - // region onomancy uses for protocol data, where whoever can write can - // remove or replace certificates (a capability the spec reserves to - // admin-delegated keys; dns-anchor.md §In the Bound Document). Throwing - // rather than dropping, because a silent no-op is indistinguishable - // from a write that worked when the read paths hide it either way. - if (entry.id === RESERVED_ONOMANCY_KEY) { + // Refused loudly. `lookup` and `list` filter these keys, so an + // unguarded write succeeds and then becomes unreadable — and it lands + // in a region another owner defines: `.well-known//` carries + // protocol and application data by the writers' convention the + // path-resolution spec assigns (onomancy's certificate list among it, + // where whoever can write can remove or replace certificates — a + // capability the spec reserves to admin-delegated keys; dns-anchor.md + // §In the Bound Document). The legacy `onomancy` container is guarded + // for the same reason during its migration window. Throwing rather + // than dropping, because a silent no-op is indistinguishable from a + // write that worked when the read paths hide it either way. + if (!isEntryId(entry.id)) { throw new Error( - `"${RESERVED_ONOMANCY_KEY}" is reserved for onomancy protocol data and cannot be used as a directory entry id.` + `"${entry.id}" is reserved for protocol data and cannot be used as a directory entry id.` ); } diff --git a/apps/component-test-app/src/composeDirectories.ts b/src/directory/compose.ts similarity index 50% rename from apps/component-test-app/src/composeDirectories.ts rename to src/directory/compose.ts index 922382a..7fca9f0 100644 --- a/apps/component-test-app/src/composeDirectories.ts +++ b/src/directory/compose.ts @@ -1,7 +1,15 @@ -import type { - DirectoryEntry, - NameDirectory, -} from "@inkandswitch/onomancy-react"; +// Field-wise composition of two directories: layering by trust and +// provenance, one set of merge rules to reason about. The motivating +// consumer is the demo's naming trust ladder — petnames over verified +// self-profiles over a shared phonebook — where each layer is written by +// someone with a different right to it and the reader wants the most +// trusted value per FIELD, not per entry. +// +// Not a cache. A directory backed by an Automerge document already has an +// offline story — its local replica — and composing a second store over it +// only shadows the shared one with writes nobody else receives. + +import type { DirectoryEntry, NameDirectory } from "./types.js"; function definedFields(entry: DirectoryEntry): DirectoryEntry { const out = { ...entry }; @@ -21,9 +29,15 @@ function mergeEntries( } /** - * One directory over two: reads prefer `primary` field by field, writes go to - * both. Here the primary is the shared directory document and the fallback is - * the localStorage copy, so names survive offline and sync when they can. + * One directory over two: reads prefer `primary` field by field, writes go + * to both. A typical pairing puts the more trusted source first — your own + * labels over a shared document, a verified layer over an unverified one — + * so a missing field falls through without the whole entry losing its + * better name. + * + * Trust is the floor, not the ceiling: the composition reports + * `unverified`, because a merged entry may carry fields from either side + * and the read path cannot attribute them. */ export function composeDirectories( primary: NameDirectory, @@ -51,8 +65,21 @@ export function composeDirectories( }, async publish(entry) { - if (primary.publish) await primary.publish(entry); - if (fallback.publish) await fallback.publish(entry); + // Both writes are attempted regardless of the first's outcome, and + // failures surface together: losing the local copy because the shared + // write rejected would trade durability for tidiness. + const outcomes = await Promise.allSettled([ + primary.publish?.(entry), + fallback.publish?.(entry), + ]); + const failures = outcomes.filter( + (outcome): outcome is PromiseRejectedResult => + outcome.status === "rejected" + ); + // Rethrown only after both writes were attempted: the caller learns + // the publish did not fully land, without the surviving write having + // been skipped on the way. + if (failures.length > 0) throw failures[0]!.reason; }, subscribe(listener) { diff --git a/src/directory/namestore.ts b/src/directory/namestore.ts new file mode 100644 index 0000000..529ab16 --- /dev/null +++ b/src/directory/namestore.ts @@ -0,0 +1,114 @@ +// Namestore edge writes, promoted from the applications so the layout +// rules cannot drift between them. +// +// A namestore is a document's own flat top-level map (onomancy +// path-resolution spec, Namestore Layout): names are bare keys — possibly +// multi-segment, `todos/groceries` — whose values are bare `automerge:` +// references, sharing the map with protocol data, directory entries, and +// anything else whose value is not a reference (and is therefore absent +// from name matching by shape). Both consuming applications shipped a +// nested layout before reading that sentence carefully; these helpers +// carry the flat rules plus the migration-window cleanup of the legacy +// container. +// +// Deliberately pure over an already-open document: this package imports +// nothing but React, so finding the document and opening a change are the +// application's (`handle.change((doc) => bindEdge(doc, …))`), as is the +// substrate's scalar-string encoding, injected as `toReference`. + +import { RESERVED_ONOMANCY_KEY } from "./automerge-directory.js"; + +/** + * A change-proxied namestore document: the mutable view an Automerge + * `change` callback receives. Values are the substrate's; these helpers + * only ever assign what `toReference` returns and delete. + */ +export type NamestoreWriteDoc = Record; + +/** + * Encode a target url as the substrate's scalar-string reference. + * + * Injected because the encoding is load-bearing and substrate-owned: a + * plain JS string assigned into an Automerge map becomes a `Text` object, + * which a conforming reader refuses (spliced merges can form a third value + * nobody wrote), so an application passes its own wrapper — for Automerge, + * `(url) => new ImmutableString(url)`. + */ +export type ToReference = (url: string) => unknown; + +/** + * Write one edge: `path` (segments joined by `/`) names `target` from this + * document. + * + * Refuses reserved paths before touching the document, writes the flat + * top-level key, and migrates its own path out of the legacy nested + * container — the retired copy would otherwise linger and resurrect if the + * flat edge were later unbound. Write paths may migrate; read paths stay + * read paths. + * + * Segment hygiene is the caller's: parse the path through the onomancy + * grammar (`Name`) before binding, so what is bound is exactly what + * resolves. + */ +export function bindEdge( + doc: NamestoreWriteDoc, + path: string, + target: string, + toReference: ToReference +): void { + refuseReservedPath(path); + + doc[path] = toReference(target); + + const legacy = doc[RESERVED_ONOMANCY_KEY]; + if (isContainer(legacy) && path in legacy) delete legacy[path]; +} + +/** + * Remove one edge, which is how a name is unbound. + * + * Deletes from both layouts, so an unbind cannot resurrect a legacy edge + * the flat one was shadowing. Guarded like {@link bindEdge}: unbinding the + * certificate list's key would delete the certificate list. + */ +export function unbindEdge(doc: NamestoreWriteDoc, path: string): void { + refuseReservedPath(path); + + delete doc[path]; + + const legacy = doc[RESERVED_ONOMANCY_KEY]; + if (isContainer(legacy) && path in legacy) delete legacy[path]; +} + +/** Refusal to touch a name under a protocol-reserved prefix. */ +export class ReservedPathError extends Error { + constructor(path: string) { + super( + `"${path}" is reserved: paths under .well-known/ carry protocol data, not names` + ); + this.name = "ReservedPathError"; + } +} + +/** + * The gate every namestore write path shares, exported so it can be tested + * without a document: it must fire before anything is touched. + * + * The `.well-known//` prefix carries protocol and application data + * by the writers' convention the path-resolution spec assigns (onomancy's + * certificate and decision lists among it). Resolvers apply no special rule + * to the prefix — exclusion from matching is by value shape — but a WRITER + * binding a name there replaces data another owner defines, while looking + * like a successful bind. The whole prefix is refused, not just onomancy's + * segment: owners are asserted rather than inherited, so a writer cannot + * know which segments are claimed. + */ +export function refuseReservedPath(path: string): void { + if (path === ".well-known" || path.startsWith(".well-known/")) { + throw new ReservedPathError(path); + } +} + +function isContainer(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/index.ts b/src/index.ts index ce23d81..639a659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,13 +28,21 @@ export type { DirectoryDocChange, } from "./directory/automerge-directory.js"; export { useAutomergeDocDirectory } from "./directory/useAutomergeDocDirectory.js"; +export { composeDirectories } from "./directory/compose.js"; +export { + bindEdge, + refuseReservedPath, + ReservedPathError, + unbindEdge, +} from "./directory/namestore.js"; +export type { NamestoreWriteDoc, ToReference } from "./directory/namestore.js"; // DNS name verification lives in `@inkandswitch/onomancy-react/onomancy`. This // entry point knows what a claim is and how to render one; it does not // resolve anything. `DnsNameStatus` carries the rules a status must follow, // whoever computes it. -export { documentDelegatesTo } from "./access/delegation.js"; +export { bareId, documentDelegatesTo } from "./access/delegation.js"; export type { DelegationVerdict, DocumentDelegationOptions, diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index ee2d38b..efb8be0 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -19,11 +19,13 @@ * still owns the only instance. */ -export { createOnomancyRuntime, parseRecordDocId } from "./runtime.js"; +export { createOnomancyRuntime } from "./runtime.js"; export type { HostnameBinding, + OnomancyClassification, OnomancyModule, OnomancyName, + OnomancyRecordCandidate, OnomancyRuntime, OnomancyRuntimeOptions, } from "./runtime.js"; @@ -38,6 +40,12 @@ export type { KeyhiveDesignationOptions, } from "./designation.js"; +export { requireReverseBinding } from "./reverse-binding.js"; +export type { + ReverseBindingCheck, + ReverseBindingClaim, +} from "./reverse-binding.js"; + export { clearVerificationCache, clearVerificationVerdicts, diff --git a/src/onomancy/reverse-binding.ts b/src/onomancy/reverse-binding.ts new file mode 100644 index 0000000..d4108e5 --- /dev/null +++ b/src/onomancy/reverse-binding.ts @@ -0,0 +1,88 @@ +// The certificate half of DNS name verification, as a designation +// combinator. Promoted from the demo application once the flat-namestore +// migration landed; the verification itself stays injected, because it +// needs the bound documents (held replicas) and the onomancy Wasm, and +// this package imports nothing but React. + +import type { DnsDesignation } from "./designation.js"; + +/** + * What a bound document said about a hostname, when asked for its + * certificate. + * + * - `accepted` — a certificate held in the document verified for this + * hostname (the application's check decides what "verified" requires, + * including any mutuality rule it applies). + * - `rejected` — evidence arrived and failed: a certificate was held and + * did not verify. + * - `absent` — the document holds no certificate for this hostname, or is + * not held here. Says nothing, in either direction. + */ +export type ReverseBindingClaim = "accepted" | "rejected" | "absent"; + +/** + * Ask one designated document whether it accepts the hostname back. + * + * `documentId` is hex, as `HostnameBinding.ids` carries it. A typical + * implementation reads the certificate list at the document's + * `.well-known/onomancy/certificates` key and verifies through the + * onomancy module (`verifyCertificate`/`verifyBinding`). + */ +export type ReverseBindingCheck = ( + documentId: string, + hostname: string +) => Promise; + +/** + * Require the reverse half of a DNS binding: the zone names the document + * (`p=`, the forward half the DNS layer proves), and the document names + * the hostname back through an onomancy certificate signed by one of its + * admin-delegated keys. The spec is explicit that a verified binding needs + * both (dns-anchor, "A verified DNS binding proves exactly this"), and a + * conforming verifier refuses when the reverse half is absent. + * + * `createKeyhiveDesignation` alone checks only that the identity + * administers the designated document. That is necessary and not + * sufficient: it shows the identity *could have* signed a certificate, + * never that one exists. Reporting `designates` on it is not a weaker + * claim than the spec's — it is a different one. + * + * So this composes the two and takes the weaker verdict: + * + * | inner says | certificate | result | + * | --- | --- | --- | + * | designates | accepted | `designates` | + * | designates | absent | `unknown` — not proven, not disproven | + * | designates | rejected | `excludes` — evidence arrived and failed | + * | anything else | — | unchanged | + * + * Absence maps to `unknown` rather than `excludes` deliberately. A document + * that carries no certificate has not *denied* the domain; it has said + * nothing, and absence of evidence is not evidence of absence. A + * certificate that arrived and failed verification is different in kind, + * and that one does convict. + * + * Every bound document gets a chance: a domain mid-migration publishes + * several, and only one need carry the certificate. One consequence of the + * composition: an inner designation that grades a bare-key binding is + * wrapped into `unknown` here, because a bare key holds no certificate. + * That is correct — `p=` names a document. + */ +export function requireReverseBinding( + check: ReverseBindingCheck, + inner: DnsDesignation +): DnsDesignation { + return async (entry, boundIds, hostname) => { + const forward = await inner(entry, boundIds, hostname); + if (forward !== "designates") return forward; + + let sawRejection = false; + for (const id of boundIds) { + const claim = await check(id, hostname); + if (claim === "accepted") return "designates"; + if (claim === "rejected") sawRejection = true; + } + + return sawRejection ? "excludes" : "unknown"; + }; +} diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 2d51d0e..6668372 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -17,16 +17,56 @@ export interface OnomancyName { free?(): void; } +/** + * A binding candidate as `classifyRecords` reports it. Structurally + * `@inkandswitch/onomancy`'s `RecordCandidate`, declared here so this + * package needs no import of its own. + */ +export interface OnomancyRecordCandidate { + /** The bound document, as an `automerge:` anchor. */ + readonly document: string; + /** The attested generation key (`g=`), canonical base64. */ + readonly generation: string; + /** The serial as a decimal string: the space is u64, past `number`. */ + readonly serial: string; +} + +/** + * The `RRset` rules' outcome over one zone's TXT strings. Structurally + * `@inkandswitch/onomancy`'s `RecordClassification`. + */ +export interface OnomancyClassification { + /** The zone's word: the unique claim at the top serial. */ + readonly selected?: OnomancyRecordCandidate; + /** Distinct claims tied at the top serial: equivocation, none picked. */ + readonly contested?: OnomancyRecordCandidate[]; + /** Bindings set aside: serial past the skew bound at the given clock. */ + readonly deferred: number; + /** Records that are not `v=ONO` at all. */ + readonly foreign: number; + /** `v=ONO` records with a tag newer than the module implements. */ + readonly unknownVersion: number; + /** `v=ONO0` records that failed the strict grammar. */ + readonly malformed: number; +} + /** * The subset of `@inkandswitch/onomancy`'s exports this package needs supplied * by the application, so that the Wasm module is loaded once and only by the * host. See `KeyhiveRuntime` for the same pattern applied to keyhive. + * + * `classifyRecords` and `docAnchorBytes` first shipped in onomancy 0.3.0; + * older builds cannot satisfy this interface. That is deliberate: the TXT + * grammar and the selection rule are a trust root's parser, and this package + * carrying its own copy is how two implementations drift — which is exactly + * what happened, three security fixes at a time, before the rules moved into + * the module every consumer already loads. */ export interface OnomancyModule { /** * Resolve a hostname's onomancy binding live over DoH, validated from the * IANA trust anchors baked into the Wasm. Resolves to - * `{ hostname, links, freshness, records: string[] }`. + * `{ hostname, records: string[], freshness, window, checkedAt, … }`. */ resolveHostname(hostname: string, dohUrl?: string | null): Promise; @@ -37,6 +77,23 @@ export interface OnomancyModule { * code that decides them everywhere else. */ Name: new (raw: string) => OnomancyName; + + /** + * The `RRset` rules over one zone's TXT strings: strict `v=ONO0` parsing, + * deferral of far-future serials before selection, highest serial wins, + * and ties contested on `(document, generation)` rather than picked. + */ + classifyRecords( + records: string[], + nowSeconds?: number | null + ): OnomancyClassification; + + /** + * The 32 payload bytes of a doc anchor — the root document id. The + * bytes-side counterpart of the `automerge:` anchors `classifyRecords` + * emits, for consumers whose own vocabulary is raw ids. + */ + docAnchorBytes(anchor: string): Uint8Array; } export interface OnomancyRuntimeOptions { @@ -49,7 +106,7 @@ export interface OnomancyRuntimeOptions { * a test that cannot name the instant can only assert whatever behaviour * it happens to observe. */ - now?: () => number; + now?: () => number | bigint; } /** @@ -78,22 +135,6 @@ export type ChainFreshness = "fresh" | "stale" | "deferred"; */ const MAX_DNS_NAME_LENGTH = 254; -/** - * How far ahead of the local clock a serial may read before it is set aside. - * - * Serials are millisecond timestamps, so a record from a publisher whose - * clock runs slightly fast is ordinary and must not be punished. A record - * from *years* ahead is not a clock — it is an attempt to jam the ratchet at - * a value nothing honest will ever exceed. - * - * The bound is what makes a ratchet safe to have at all. With it, a transient - * attacker can push the ratchet at most five minutes past wall clock, and an - * honest publisher — minting `max(now_ms, last + 1)` — outgrows the poison - * within the window. Without it, one forged record locks the name forever, - * and the ratchet becomes the attack rather than the defence. - */ -const SERIAL_SKEW_BOUND_MS = 5n * 60n * 1000n; - /** * A DNSSEC-verified binding: the root document ids a hostname's * `_onomancy` TXT records designate. @@ -118,6 +159,12 @@ export interface HostnameBinding { * across two queries — never two records in one answer. */ serial?: bigint; + /** + * Records of equal top precedence disagree on `(document, generation)`. + * The zone has failed to say what it designates; nothing here can be + * treated as the binding. + */ + contested?: boolean; /** * How many records were set aside for reading too far in the future. * @@ -158,6 +205,14 @@ export interface HostnameBinding { clockSkewSeconds?: number; } +/** + * Custom implementations note: `resolveBoundIds` MUST set `contested` when + * records of equal top precedence disagree on `(document, generation)` — + * including the same-document/different-generation case, which is carried + * ONLY by the flag (the ids list has one entry, so a consumer cannot infer + * the contest from it). An implementation that omits the flag silently + * verifies mid-rotation zones. + */ export interface OnomancyRuntime { /** * The DNSSEC-verified root document ids bound to `hostname`. @@ -199,13 +254,45 @@ export function createOnomancyRuntime( options.dohUrl ?? null ); const freshness = freshnessOf(outcome); - const nowMs = BigInt(Math.floor((options.now ?? Date.now)())); - const selection = boundIdsOf(outcome, nowMs); - const binding: HostnameBinding = { hostname, ids: selection.ids }; - if (selection.serial !== undefined) binding.serial = selection.serial; - if (selection.deferredSerials > 0) { - binding.deferredSerials = selection.deferredSerials; + // The module's clock argument is epoch seconds (it refuses a + // milliseconds reading as implausible); the injectable option stays + // milliseconds because `Date.now` is the ordinary source. Flooring + // widens the deferral horizon by under a second, which the spec's + // five-minute skew bound dwarfs. + const rawNow = (options.now ?? Date.now)(); + const nowSeconds = + typeof rawNow === "bigint" + ? Number(rawNow / 1000n) + : Math.floor(rawNow / 1000); + + // The RRset rules live in the module — strict grammar, deferral + // before selection, highest serial wins, ties contested on the pair + // (document, generation) — so this package holds no parser of its + // own to drift. What remains here is the mapping to this package's + // id vocabulary (hex), via the module's own anchor decoder. + const classified = onomancy.classifyRecords( + recordsOf(outcome), + nowSeconds + ); + + const leaders = classified.selected + ? [classified.selected] + : (classified.contested ?? []); + const ids = [ + ...new Set( + leaders.map((claim) => + bytesToHex(onomancy.docAnchorBytes(claim.document)) + ) + ), + ]; + + const binding: HostnameBinding = { hostname, ids }; + const [first] = leaders; + if (first !== undefined) binding.serial = BigInt(first.serial); + if (classified.contested) binding.contested = true; + if (classified.deferred > 0) { + binding.deferredSerials = classified.deferred; } if (freshness !== undefined) binding.freshness = freshness; @@ -313,81 +400,6 @@ function validityWindowOf( return expiration >= inception ? { inception, expiration } : undefined; } -/** The outcome of choosing among a hostname's `v=ONO0` records. */ -interface RecordSelection { - ids: string[]; - serial?: bigint; - deferredSerials: number; -} - -/** - * The `p=` document ids a `resolveHostname` outcome designates, with the - * serial that won and a count of records set aside as future-dated. - * - * Order of operations is load-bearing and comes from the spec: **deferral - * precedes movement.** A record reading too far ahead is set aside *before* - * selection, so it can never become the winner and therefore never reaches - * the ratchet. Reversing these two steps would let a forged far-future - * serial jam the ratchet at a value no honest publisher will ever exceed — - * turning the defence into the attack. - */ -function boundIdsOf(outcome: unknown, nowMs: bigint): RecordSelection { - const none: RecordSelection = { ids: [], deferredSerials: 0 }; - if (typeof outcome !== "object" || outcome === null) return none; - const records = (outcome as { records?: unknown }).records; - if (!Array.isArray(records)) return none; - - // Parse every record, keeping only those that are `v=ONO0` and well formed. - // A foreign or malformed TXT record beside a valid one is normal — a zone - // holds records for many purposes — so an unparseable neighbour must not - // fail the set. - const parsed: Ono0Record[] = []; - for (const record of records) { - if (typeof record !== "string") continue; - const ono0 = parseRecord(record); - if (ono0 !== undefined) parsed.push(ono0); - } - - // Set aside anything dated beyond the skew bound. Deferred, not rejected: - // these ripen as the clock advances, so a publisher whose clock runs a - // little fast is delayed rather than refused. - const horizon = nowMs + SERIAL_SKEW_BOUND_MS; - const eligible = parsed.filter((record) => record.serial <= horizon); - const deferredSerials = parsed.length - eligible.length; - - if (eligible.length === 0) return { ids: [], deferredSerials }; - - // Highest serial wins. RRset order is *not* significant — a resolver may - // return the same set in a different order on each query — so taking - // `records[0]` would make the answer depend on which shuffle arrived. - // The serial is the publisher's own statement of which record supersedes. - // - // Compared as `bigint` throughout. The serial space is u64, and `Number` - // silently equates neighbours near its top — which would turn a genuine - // supersession into a tie, and a tie is reported as a contested zone. A - // domain correctly superseding its own record would show to every visitor - // as misconfigured. `Math.max` is avoided for the same reason: it coerces - // back through `number` at precisely the comparison the bigint exists to - // protect. - let top = eligible[0]!.serial; - for (const record of eligible) if (record.serial > top) top = record.serial; - - const leaders = eligible.filter((record) => record.serial === top); - const distinct = [...new Set(leaders.map((record) => record.docIdHex))]; - - // Agreement at the top serial, including the ordinary single-record case. - if (distinct.length === 1) { - return { ids: distinct, serial: top, deferredSerials }; - } - - // Disagreement at the same serial: two records claim to be equally current - // and name different documents. There is no ground here for preferring - // either — that is the definition of the tie. Returning all of them lets - // the caller see a contested binding and refuse it; returning one would - // manufacture a verdict the zone does not support. - return { ids: distinct, serial: top, deferredSerials }; -} - /** * The chain grade in a `resolveHostname` outcome, when it reported one. * @@ -403,76 +415,19 @@ function freshnessOf(outcome: unknown): ChainFreshness | undefined { : undefined; } -/** One parsed `v=ONO0` TXT record. */ -export interface Ono0Record { - /** The hex-encoded root document id from `p=`. */ - readonly docIdHex: string; - /** - * The `n=` serial, as a `BigInt`. - * - * Not a `number`. The serial space is the full u64 range — - * `max(now_ms, last + 1)` is a publisher *recommendation*, not a bound, and - * verifiers must accept any u64. `u64::MAX` is 18446744073709551615 against - * `Number.MAX_SAFE_INTEGER` of 9007199254740991, so a conformant serial can - * exceed what a `number` represents exactly. - * - * Every serial in the wild today is a millisecond timestamp (~1.8e12) and - * would survive as a `number`, which is exactly why this would break - * silently and late. Do not "simplify" it back on the grounds that the - * grammar already caps the digit count — 20 digits is the u64 limit, not the - * safe-integer limit. - */ - readonly serial: bigint; -} - -/** - * Canonical decimal, per the DNS anchoring spec: no leading zeros, at most 20 - * digits, no sign, no whitespace. `Serial::parse` upstream rejects each of - * those with a distinct error (`LeadingZero`, `TooManyDigits`, `Overflow`); - * we only need the same verdict, not the same diagnosis. - */ -const ONO0 = - /^v=ONO0;k=ed25519;n=(0|[1-9][0-9]{0,19});g=[A-Za-z0-9+/]+={0,2};p=([A-Za-z0-9+/]+={0,2})$/; - -const U64_MAX = 18446744073709551615n; - /** - * One TXT record parsed, or `undefined` when it is not a well-formed `v=ONO0` - * record. Parsing is strict within the known tag, per the DNS anchoring spec: - * exact field order, known fields only, canonical integers. - * - * Strictness in this direction is the safe one. Accepting a record the - * protocol rejects means resolving a name a conformant verifier refuses — two - * users, same zone, different answers, no error anywhere. - * - * Hand-written on purpose: this is the TXT wire format, which `Name` does not - * parse. `Name` decides what a *name* is; this decides what a *record* is. - * Upstream has `TxtRecord::parse` and `classify()` already written; when they - * are exposed to JS this whole function should be deleted rather than - * maintained. + * The TXT strings in a `resolveHostname` outcome. Read structurally: the + * module is injected, so the build in play is whatever the consumer + * installed, and a missing or oddly-shaped field is an empty set rather + * than a throw. A foreign or malformed neighbour is the classifier's to + * tally, not ours to pre-filter — only non-strings are dropped, since the + * classifier refuses those wholesale. */ -export function parseRecord(record: string): Ono0Record | undefined { - const match = record.match(ONO0); - if (!match) return undefined; - - const serial = BigInt(match[1]); - if (serial > U64_MAX) return undefined; - - const bytes = base64ToBytes(match[2]); - if (bytes === undefined || bytes.length !== 32) return undefined; - - return { docIdHex: bytesToHex(bytes), serial }; -} - -/** The hex-encoded root document id of one TXT record. See {@link parseRecord}. */ -export function parseRecordDocId(record: string): string | undefined { - return parseRecord(record)?.docIdHex; -} - -function base64ToBytes(base64: string): Uint8Array | undefined { - try { - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - } catch { - return undefined; - } +function recordsOf(outcome: unknown): string[] { + if (typeof outcome !== "object" || outcome === null) return []; + const records = (outcome as { records?: unknown }).records; + if (!Array.isArray(records)) return []; + return records.filter( + (record): record is string => typeof record === "string" + ); } diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index d610a88..cb4cad1 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -1,3 +1,4 @@ +import { bareId } from "../access/delegation.js"; import type { DirectoryEntry, DnsNameStatus, @@ -19,6 +20,8 @@ type Resolution = | { phase: "resolved"; ids: string[]; + /** Equal-precedence records disagree on (document, generation). */ + contested?: boolean; freshness?: ChainFreshness; lapsedSeconds?: number; } @@ -43,9 +46,11 @@ type Verdict = * `resolutions` is the DNS layer: hostname to bound document ids. It is the * onomancy spec's *binding cache*, which requires entries to be re-verified * at use — a decision that depends on `now`, so memoizing it across time is - * memoizing a function of an argument that was dropped. Doing that properly - * needs certificate verification, which the Wasm binding does not expose, so - * this half stays memoized and the limitation is recorded rather than hidden. + * memoizing a function of an argument that was dropped. The Wasm module now + * exposes the verification entry points (`verifyCertificate`, + * `verifyBinding`), but re-verifying at use also needs the bound documents + * held locally, which this wrapper never sees — so this half stays memoized + * and the limitation is recorded rather than hidden. * * `verdicts` is the designation layer: does the bound document belong to this * identity? That is a question about local keyhive state and the DNS spec has @@ -293,17 +298,25 @@ export function createOnomancyDirectory( : { phase: "no-claim" } ); } else if (isReplay(hostname, binding)) { + // Deliberate ordering: a replayed serial reads `replayed` even if + // the replayed set is also contested — the replay is the stronger + // statement (this exact data is known superseded). // A stale chain bearing a serial no higher than one already // accepted for this name. The zone — or something on the path — // is serving a record we know to be superseded. resolutions.set(hostname, { phase: "replayed" }); } else { - admitToRatchet(hostname, binding); + // The ratchet remembers the highest serial ACCEPTED. A contested + // set was refused, not accepted: admitting its serial would make a + // zone that heals the rotation without bumping the serial read + // `replayed` forever on stale chains. + if (!binding.contested) admitToRatchet(hostname, binding); const resolved: Resolution = { phase: "resolved", ids: binding.ids.map(bareId), }; + if (binding.contested) resolved.contested = true; if (binding.freshness !== undefined) { resolved.freshness = binding.freshness; } @@ -418,7 +431,12 @@ export function createOnomancyDirectory( // an answer about *this entry*, and the zone has not made one. The // remedy belongs to whoever controls the DNS records, so the status must // not read as "wait". - if (resolution.ids.length > 1) { + // Two triggers: distinct documents at the tied top serial (shows up in + // ids), or same document with different generation keys (carried only by + // the contested flag the selection set). The second check also keeps + // custom OnomancyRuntime implementations honest that report multiple ids + // without the flag. + if (resolution.contested || resolution.ids.length > 1) { return { ...entry, dnsNameStatus: "contested" }; } @@ -501,10 +519,6 @@ export function createOnomancyDirectory( return directory; } -function bareId(id: string): string { - return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); -} - /** * Why the DNS layer gave no answer, from the runtime's own `reason` when it * supplies one. diff --git a/tests/automerge-directory.test.mjs b/tests/automerge-directory.test.mjs new file mode 100644 index 0000000..fadc04e --- /dev/null +++ b/tests/automerge-directory.test.mjs @@ -0,0 +1,63 @@ +// Behaviour pins for the shared-document directory under the flat +// namestore layout, where directory entries, namestore edges (scalar +// strings), and protocol data (`.well-known/…`) share one top-level map. +// +// Each pin verified to go red against a build without its guard: an +// unguarded publish at a protocol key succeeds and then becomes +// unreadable, replacing data another owner defines — the certificate +// list among it, whose replacement silently degrades every verified +// `@host` badge for the document. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { createAutomergeDocDirectory } = await import("../dist/index.js"); + +test("publish refuses protocol keys, loudly", () => { + const doc = {}; + const directory = createAutomergeDocDirectory(doc, (fn) => fn(doc)); + + for (const id of [ + "onomancy", // the legacy container, guarded through its migration window + ".well-known", + ".well-known/onomancy/certificates", + ".well-known/other-app/data", + ]) { + assert.throws( + () => directory.publish({ id, name: "Mallory" }), + /reserved/, + `${id} must refuse` + ); + } + + // A prefix rule, not a substring rule. + directory.publish({ id: ".well-knownish", name: "odd but allowed" }); + assert.equal(directory.lookup(".well-knownish")?.name, "odd but allowed"); +}); + +test("reads skip protocol keys and non-entry shapes, by key and by shape", () => { + const alice = "aa".repeat(32); + const doc = { + [alice]: { name: "Alice" }, + // A flat namestore edge: a scalar-string wrapper carrying its text in + // `val`, exactly how one reads back out of an Automerge document. + "todos/groceries": { val: "automerge:2AbCdEf" }, + // The certificate list: an array, not an entry. + ".well-known/onomancy/certificates": [new Uint8Array([1, 2, 3])], + // The legacy nested container. + onomancy: { "old/name": "automerge:2AbCdEf" }, + }; + const directory = createAutomergeDocDirectory(doc, undefined); + + assert.deepEqual( + directory.list().map((entry) => entry.id), + [alice], + "exactly the entries, nothing spread from an edge or a list" + ); + assert.equal(directory.lookup("todos/groceries"), undefined); + assert.equal( + directory.lookup(".well-known/onomancy/certificates"), + undefined + ); + assert.equal(directory.lookup("onomancy"), undefined); + assert.equal(directory.lookup(alice)?.name, "Alice"); +}); diff --git a/tests/conformance-vectors.test.mjs b/tests/conformance-vectors.test.mjs new file mode 100644 index 0000000..5d1092c --- /dev/null +++ b/tests/conformance-vectors.test.mjs @@ -0,0 +1,165 @@ +// Shared ONO0 conformance vectors, authored by keyhive-todo-app-demo from +// their record tests plus the 2026-09-02/03 canonical rulings; their home +// is onomancy's conformance table. Vendored (not read from the bridge) so +// the suite is hermetic. +// +// Rev history: rev 1 caught this parser's lax g= and missing 255-char limit; +// rev 2 (canonical referee) caught the demo's missing skew deferral; rev 3 +// (reference harness) caught missing point-validity in BOTH parsers and +// non-point fixture keys in the vectors themselves; rev 4 pinned strict +// base64 canonicality and the two decompression edges no fill fixture can +// reach — which the verification run then caught the Rust decoder accepting. +// Each round's bug was invisible to the previous round's process. +// +// This library no longer carries the rules the vectors judge: parsing and +// selection moved into `@inkandswitch/onomancy` (`classifyRecords`), and +// what remains here is `createOnomancyRuntime`'s mapping onto +// `HostnameBinding`. The replay therefore runs at two levels — parse +// vectors against the module the app injects, classify vectors through the +// runtime — so a regression in either the rules or the mapping goes red. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { URL } from "node:url"; +import { Buffer } from "node:buffer"; + +const onomancy = await import("@inkandswitch/onomancy"); +const { createOnomancyRuntime } = await import("../dist/onomancy/index.js"); + +const vectors = fs + .readFileSync(new URL("./ono0-conformance-vectors.jsonl", import.meta.url)) + .toString() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + +// Guard against silent degradation: a truncated or partially-unparsed vector +// file must fail loudly, not pass vacuously. Counts pinned to rev 4. +test("vector file carries the full rev 4 corpus", () => { + const byKind = {}; + for (const v of vectors) + byKind[v.kind ?? "meta"] = (byKind[v.kind ?? "meta"] ?? 0) + 1; + assert.deepEqual(byKind, { meta: 1, parse: 25, classify: 15, nextSerial: 7 }); + assert.equal(vectors.find((v) => v.kind === "meta")?.revision, 4); +}); + +// The meta's document aliases, resolved to the hex ids our API reports. +const DOC_ALIAS = { + doc1: Buffer.from(new Uint8Array(32).fill(1)).toString("hex"), + doc2: Buffer.from(new Uint8Array(32).fill(3)).toString("hex"), +}; + +const SKEW_MS = 300000n; + +// A fixed, realistic instant where the vectors leave the clock open. +const FIXED_NOW_MS = 1788000000000n; + +// The module refuses implausible clocks (its seconds-vs-milliseconds +// validation), so a vector needing one is exercised against the internal +// Rust rule only — sanctioned by the vectors' meta. +const IMPLAUSIBLE_SECONDS = 100000000000n; + +/** A clock (ms) at which `serial` escapes deferral, or undefined. */ +const clockForMs = (serial) => { + const value = BigInt(serial); + const floor = value > SKEW_MS ? value - SKEW_MS : 0n; + if (floor / 1000n >= IMPLAUSIBLE_SECONDS) return undefined; + return floor > FIXED_NOW_MS ? floor : FIXED_NOW_MS; +}; + +const classify = async (records, nowMs) => + createOnomancyRuntime( + { ...onomancy, resolveHostname: async () => ({ records }) }, + { now: () => Number(nowMs ?? FIXED_NOW_MS) } + ).resolveBoundIds("x.example"); + +for (const v of vectors) { + if (v.kind === "parse") { + // Module level: the rules themselves, disposition by disposition. + test(`parse: ${v.name}`, () => { + if (v.expected === "parsed") { + const nowMs = + v.serial === undefined ? FIXED_NOW_MS : clockForMs(v.serial); + if (nowMs === undefined) { + // No plausible clock admits the serial (the u64 ceiling), but + // only a grammatical binding can land in `deferred`. + const out = onomancy.classifyRecords( + [v.input], + Number(FIXED_NOW_MS / 1000n) + ); + assert.equal(out.malformed + out.foreign + out.unknownVersion, 0); + assert.equal(out.deferred, 1); + return; + } + const out = onomancy.classifyRecords([v.input], Number(nowMs / 1000n)); + assert.equal(out.malformed + out.foreign + out.unknownVersion, 0); + assert.equal(out.selected?.serial, v.serial); + } else { + const out = onomancy.classifyRecords( + [v.input], + Number(FIXED_NOW_MS / 1000n) + ); + assert.equal(out[v.expected], 1, `expected one ${v.expected}`); + assert.equal(out.selected, undefined); + assert.equal(out.contested, undefined); + } + }); + } else if (v.kind === "classify") { + // Runtime level: the same rules THROUGH resolveBoundIds, plus the + // mapping to hex ids, the contested flag, and the deferral count. + if ( + v.nowMs !== undefined && + BigInt(v.nowMs) / 1000n >= IMPLAUSIBLE_SECONDS + ) { + // The module refuses a clock this far out as implausible, so the + // vector is exercised against the internal Rust rule only (vectors' + // meta, `nowSecondsCaveat`). + test(`classify: ${v.name}`, { skip: "clock beyond the plausible bound" }); + continue; + } + test(`classify: ${v.name}`, async () => { + const b = await classify( + v.input, + v.nowMs !== undefined ? BigInt(v.nowMs) : undefined + ); + const status = b.contested + ? "contested" + : b.ids.length + ? "bound" + : "unbound"; + assert.equal(status, v.expected.status); + if (v.expected.serial !== undefined) { + assert.equal(String(b.serial), String(v.expected.serial)); + } + if (v.expected.status === "bound") { + assert.equal(b.ids.length, 1); + // The document identity, not just the count: a wire-order-dependent + // selection with the right serial passed this suite before this + // assertion existed. + if (v.expected.document) { + const want = DOC_ALIAS[v.expected.document]; + assert.ok(want, `unknown document alias ${v.expected.document}`); + assert.equal(b.ids[0], want); + } + } + if (typeof v.expected.documents === "number") { + assert.equal(b.ids.length, v.expected.documents); + } + // Meta: deferred is 0 when omitted — assert always, not only when set, + // so a fabricated deferredSerials on a bound answer cannot survive. + assert.equal(b.deferredSerials ?? 0, v.expected.deferred ?? 0); + }); + } else if (v.kind === "nextSerial") { + // Module level: this library has no publisher, but the rule is one + // import away and the vectors are already in hand. + test(`nextSerial: ${v.name}`, () => { + const last = v.last === null ? undefined : v.last; + const now = Number(v.now); + if (v.expected === "refuse") { + assert.throws(() => onomancy.nextSerial(last, now)); + } else { + assert.equal(onomancy.nextSerial(last, now), v.expected); + } + }); + } +} diff --git a/tests/namestore.test.mjs b/tests/namestore.test.mjs new file mode 100644 index 0000000..627b755 --- /dev/null +++ b/tests/namestore.test.mjs @@ -0,0 +1,139 @@ +// Behaviour pins for the surfaces promoted out of the applications once +// the flat-namestore migration landed: the namestore write helpers, the +// reverse-binding designation combinator, and the directory composition. +// Promoted precisely so the rules cannot drift between consumers, which +// makes these pins the single place the rules are stated executable. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { bindEdge, unbindEdge, composeDirectories } = + await import("../dist/index.js"); +const { requireReverseBinding } = await import("../dist/onomancy/index.js"); + +// A stand-in for the substrate's scalar-string wrapper: the helpers only +// ever assign what `toReference` returns, so any marker shape proves the +// injection is honored. +const toReference = (url) => ({ val: url }); + +test("bindEdge writes the flat key through the injected reference", () => { + const doc = {}; + bindEdge(doc, "todos/groceries", "automerge:2AbC", toReference); + assert.deepEqual(doc["todos/groceries"], { val: "automerge:2AbC" }); +}); + +test("a rebind migrates its own path out of the legacy container", () => { + const doc = { onomancy: { "todos/groceries": "automerge:2Old" } }; + bindEdge(doc, "todos/groceries", "automerge:2New", toReference); + assert.deepEqual(doc["todos/groceries"], { val: "automerge:2New" }); + assert.equal( + "todos/groceries" in doc.onomancy, + false, + "the retired copy must not linger to resurrect on a later unbind" + ); +}); + +test("unbindEdge deletes from both layouts", () => { + const doc = { + "todos/groceries": { val: "automerge:2New" }, + onomancy: { "todos/groceries": "automerge:2Old" }, + }; + unbindEdge(doc, "todos/groceries"); + assert.equal("todos/groceries" in doc, false); + assert.equal("todos/groceries" in doc.onomancy, false); +}); + +test("both write helpers refuse reserved paths before touching the document", () => { + const doc = {}; + for (const path of [ + ".well-known", + ".well-known/onomancy/certificates", + ".well-known/other-app/data", + ]) { + assert.throws( + () => bindEdge(doc, path, "automerge:2AbC", toReference), + /reserved/, + `bind at ${path}` + ); + assert.throws(() => unbindEdge(doc, path), /reserved/, `unbind at ${path}`); + } + assert.deepEqual(doc, {}, "nothing was written on any refusal"); + + // A prefix rule, not a substring rule. + bindEdge(doc, ".well-knownish", "automerge:2AbC", toReference); + assert.ok(doc[".well-knownish"]); +}); + +test("requireReverseBinding takes the weaker verdict", async () => { + const entry = { id: "aa".repeat(32), name: "Alice" }; + const inner = (verdict) => async () => verdict; + const check = (answers) => async (id) => answers[id] ?? "absent"; + + const designation = (innerVerdict, answers) => + requireReverseBinding(check(answers), inner(innerVerdict))( + entry, + Object.keys(answers), + "a.example" + ); + + // The decision table, row by row. + assert.equal( + await designation("designates", { doc1: "accepted" }), + "designates" + ); + assert.equal( + await designation("designates", { doc1: "absent" }), + "unknown", + "no certificate is silence, not denial" + ); + assert.equal( + await designation("designates", { doc1: "rejected" }), + "excludes", + "evidence that arrived and failed convicts" + ); + // One accepting document among several is enough (mid-migration + // dual-publish), and acceptance beats a sibling's rejection. + assert.equal( + await designation("designates", { doc1: "rejected", doc2: "accepted" }), + "designates" + ); + // Anything but designates passes through untouched: the reverse half is + // only asked about documents the forward half already granted. + assert.equal(await designation("excludes", { doc1: "accepted" }), "excludes"); + assert.equal(await designation("unknown", { doc1: "accepted" }), "unknown"); +}); + +test("composed publish attempts both writes even when one rejects", async () => { + const base = { + source: "x", + trust: "unverified", + writable: true, + enumerable: true, + notice: "", + lookup: () => undefined, + list: () => [], + }; + let fallbackSaw; + const primary = { + ...base, + publish: async () => { + throw new Error("shared write rejected"); + }, + }; + const fallback = { + ...base, + publish: async (entry) => { + fallbackSaw = entry; + }, + }; + + const composed = composeDirectories(primary, fallback); + await assert.rejects( + () => composed.publish({ id: "aa".repeat(32), name: "Alice" }), + /shared write rejected/ + ); + assert.equal( + fallbackSaw?.name, + "Alice", + "the local write must not be lost to the shared write's rejection" + ); +}); diff --git a/tests/ono0-conformance-vectors.jsonl b/tests/ono0-conformance-vectors.jsonl new file mode 100644 index 0000000..4f5e0f9 --- /dev/null +++ b/tests/ono0-conformance-vectors.jsonl @@ -0,0 +1,48 @@ +{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts + namestore guard suite (44 tests total across the repo) + canonical rulings 2026-09-02/03","date":"2026-09-02","revision":4,"revisionNote":"rev 4: base64 canonicality RULED strict (reference decode_key_field: canonical padded STANDARD, trailing bits rejected) - two spelling vectors added; x=0-sign-bit and y>=p decompression vectors added (unreachable by fill fixtures); horizon-masked contest pinned; documents:1 stated on the rotation tie; expected.document now present on every bound classify vector - ASSERT IT: a wire-order-first document mutant passed a 39-test runner that checked status+serial only. Earlier, rev 3: all key/document fixtures moved to point-valid fills (docs 1/3/11, keys 6/9) - rev-2 fixtures fill(2)/fill(7) are not ed25519 curve points, so seven rev-2 classify expectations were canonically unsatisfiable; two non-point parse vectors added; point-validity reference table pinned in the source suite. Earlier, rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","nowSecondsCaveat":"the JS classifyRecords surface takes nowSeconds and refuses clocks past ~year 5138, so the u64-adjacent vector is exercised via the internal Rust rule only - by design (per ono)","documents":"a COUNT of distinct documents, not a list; bound implies exactly one. On contested it can be 1: a rotation tie (one document, two generation keys).","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,3); all key/doc fixtures are verified curve points except NONPOINT = fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations","runners":"assert expected.document on every bound vector, not just status+serial - a wire-order-first selection mutant is invisible otherwise (mutation-proven against a 39-test runner)"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} +{"kind":"parse","name":"valid record round-trips","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"7","gBytes":32,"pBytes":32} +{"kind":"parse","name":"live record: brooklynzelenka.com","input":"v=ONO0;k=ed25519;n=1787792719795;g=8XXKlRi7D9msSp8U4TZo0AzQ99InBDquIYprrW7NoI4=;p=nJ8I/xDYHbttOOpAzRaYFgGpvdtYmlGuXNsaNKWz+Us=","expected":"parsed","serial":"1787792719795","note":"captured from live DoH+DNSSEC; pins the format production already serves"} +{"kind":"parse","name":"serial at u64 ceiling","input":"v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"18446744073709551615"} +{"kind":"parse","name":"serial beyond u64","input":"v=ONO0;k=ed25519;n=18446744073709551616;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"canonical: Overflow"} +{"kind":"parse","name":"leading-zero serial","input":"v=ONO0;k=ed25519;n=01;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","overturned":{"old":"parsed as 1 (regex \\d+ then BigInt)","ruling":"canonical decimal only: no leading zero"}} +{"kind":"parse","name":"zero serial","input":"v=ONO0;k=ed25519;n=0;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"0"} +{"kind":"parse","name":"non-integer serial","input":"v=ONO0;k=ed25519;n=1.5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"non-numeric serial","input":"v=ONO0;k=ed25519;n=abc;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"short g= (1 byte)","input":"v=ONO0;k=ed25519;n=7;g=AA==;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","overturned":{"old":"parsed: g= length was unconstrained here, and a test asserted it","ruling":"g= decodes via the same key-field rule as p=: exactly 32 bytes"}} +{"kind":"parse","name":"empty g=","input":"v=ONO0;k=ed25519;n=7;g=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"short p= (1 byte)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AA==","expected":"malformed"} +{"kind":"parse","name":"33-byte p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD","expected":"malformed"} +{"kind":"parse","name":"32-byte non-point p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","expected":"malformed","note":"ratified grammar (specs/serialization.md, 2026-08-19): key fields MUST decompress to curve points; fill(2) is the reference non-point. Added rev 3: BOTH TS parsers accepted this until ono's harness refused the fixtures."} +{"kind":"parse","name":"32-byte non-point g=","input":"v=ONO0;k=ed25519;n=7;g=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"same rule, and the sharper half: g= is never verified against during resolution, and the record is malformed anyway - parsers must agree on whether a record EXISTS."} +{"kind":"parse","name":"non-canonical base64: dirty spare padding bits","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQF=","expected":"malformed","note":"RULED (reference decode_key_field, base64 STANDARD: canonical padding, trailing bits rejected; spec: unique canonical spelling, parse-then-to_string is the identity). Forgiving decoders (atob, Buffer.from base64) equate this with the canonical spelling of DOC1 - both TS parsers did, found independently in both repos' round-2 reviews."} +{"kind":"parse","name":"non-canonical base64: missing padding","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","expected":"malformed","note":"same ruling: 44 chars of canonical padded base64, exactly."} +{"kind":"parse","name":"x=0 with sign bit set (y=1, bit 255)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA=","expected":"malformed","note":"RFC 8032 5.1.3 step 4: the negative-zero spelling encodes no point. Unreachable by any fill fixture; mutation testing showed it otherwise unguarded."} +{"kind":"parse","name":"y >= p (fill 0xff)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=//////////////////////////////////////////8=","expected":"malformed","note":"non-canonical field element: masked y = 2^255-1 past p = 2^255-19."} +{"kind":"parse","name":"fields out of order","input":"v=ONO0;k=ed25519;n=1;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=","expected":"malformed","note":"fixed order, exactly 5 fields"} +{"kind":"parse","name":"unknown field appended","input":"v=ONO0;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;x=1","expected":"malformed"} +{"kind":"parse","name":"unknown algorithm","input":"v=ONO0;k=x25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"ruling: malformed, NOT foreign"} +{"kind":"parse","name":"future version","input":"v=ONO1;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"unknownVersion","note":"skip-as-future; this repo's parser collapses to skip"} +{"kind":"parse","name":"foreign TXT","input":"v=SPF1 include:example.com","expected":"foreign"} +{"kind":"parse","name":"empty string","input":"","expected":"foreign"} +{"kind":"parse","name":"over 255 chars","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= ","expected":"malformed","note":"TXT character-string ceiling. HONESTY: this vector cannot discriminate a ceiling-enforcing parser from one without the check - a grammar-conforming record maxes out at 133 chars, so this input already fails the grammar. Defence-in-depth documentation, mutation-proven unable to go red on its own."} +{"kind":"classify","name":"highest serial wins regardless of wire order","input":["v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"}} +{"kind":"classify","name":"rotation invariance","input":["v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"},"note":"answer must be invariant under any permutation of the set"} +{"kind":"classify","name":"contested: two documents at tied top serial","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"contested","serial":"7","documents":2},"note":"refuse to choose; contested is distinct from unbound"} +{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgY=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7","documents":1},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} +{"kind":"classify","name":"duplicate record is agreement","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc1"}} +{"kind":"classify","name":"older record loses without contesting","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"7","document":"doc1"}} +{"kind":"classify","name":"contest at a lower serial is ignored","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws="],"expected":{"status":"bound","serial":"7","document":"doc1"},"note":"added on review: our property tested rotation, not this"} +{"kind":"classify","name":"unparseable records do not deny the set","input":["v=SPF1 include:example.com","","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=x25519;n=9;g=AA==;p=AA=="],"expected":{"status":"bound","serial":"3","document":"doc1"}} +{"kind":"classify","name":"u64-adjacent serials stay distinct","nowMs":"18446744073709551615","input":["v=ONO0;k=ed25519;n=18446744073709551614;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"18446744073709551615","document":"doc2"},"note":"REVISED: previous revision carried no nowMs, so canonical semantics deferred both records and the vector pinned this repo's pre-deferral behaviour. Explicit ceiling clock keeps the BigInt-distinctness point: Number coercion equates these two serials."} +{"kind":"classify","name":"far-future serial is deferred, honest record wins","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000000000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"1800000000000","document":"doc1","deferred":1},"overturned":{"old":"bound to the forged far-future serial: this repo's selectBinding had no skew deferral, so a planted serial won selection while the README described the defence","ruling":"deferral precedes selection (verifier/state.rs); serial > nowMs + 300000 is set aside before the max is taken"},"note":"THE poisoning-bound vector: the forged record is exactly 1ms past the bound"} +{"kind":"classify","name":"only deferred records is nothing-usable-yet, not unbound-silent","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300002;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"unbound","deferred":2},"note":"deferred count distinguishes a silent domain from a jammed one"} +{"kind":"classify","name":"serial exactly at the skew bound is legitimate","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"1800000300000","document":"doc1"},"note":"boundary: <= nowMs + 300000 selects; deferral starts strictly past it"} +{"kind":"classify","name":"a contest entirely beyond the skew horizon is masked by deferral","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"unbound","deferred":2},"note":"deferral precedes selection, so a STAGED far-future contest cannot jam the name - it is set aside whole. Correct canonical order; previously unpinned in all three implementations."} +{"kind":"classify","name":"empty set","input":[],"expected":{"status":"unbound","deferred":0}} +{"kind":"classify","name":"nothing usable","input":["v=SPF1","junk"],"expected":{"status":"unbound","deferred":0}} +{"kind":"nextSerial","name":"first mint takes the clock","last":null,"now":"1000","expected":"1000"} +{"kind":"nextSerial","name":"clock ahead: track it","last":"1000","now":"9000","expected":"9000"} +{"kind":"nextSerial","name":"same-ms collision: bump","last":"1000","now":"1000","expected":"1001"} +{"kind":"nextSerial","name":"backwards clock: supersede anyway","last":"5000","now":"4000","expected":"5001"} +{"kind":"nextSerial","name":"one below ceiling mints the ceiling","last":"18446744073709551614","now":"1000","expected":"18446744073709551615"} +{"kind":"nextSerial","name":"at the ceiling: refuse","last":"18446744073709551615","now":"1000","expected":"refuse","overturned":{"old":"returned 2^64, writer then emitted a record every parser rejects","ruling":"refuse (throw): saturating mints an unsupersedable record, wrapping mints one that loses to everything"}} +{"kind":"nextSerial","name":"clock beyond ceiling: refuse","last":null,"now":"18446744073709551616","expected":"refuse"} diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs index 59b8f99..4e138d1 100644 --- a/tests/verified-directory.test.mjs +++ b/tests/verified-directory.test.mjs @@ -1,12 +1,18 @@ // Behaviour pins for the onomancy directory wrapper, run against the built -// library (`pnpm build` first; CI builds before testing). Bare `node --test`: -// dist/ imports only React-free modules on these paths, which -// check-isolation.mjs guarantees. +// library (`pnpm build` first; CI builds before testing). Bare `node --test` +// works because the dist modules these paths load keep their dependencies +// injectable; note dist DOES import react (check-isolation.mjs allows +// exactly that), so a copied-out dist without node_modules will not load. // // Each pin exists because every other gate passes with its behaviour // reverted — these were verified to fail against pre-fix builds. import { test } from "node:test"; import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import fs from "node:fs"; +import { setTimeout } from "node:timers"; +import console from "node:console"; +import { URL } from "node:url"; const { createOnomancyDirectory, @@ -14,13 +20,14 @@ const { createVerificationCache, } = await import("../dist/onomancy/index.js"); +// The real module, with only resolution faked: the grammar, the RRset +// rules, and the anchor decoder are never stubbed, so these pins exercise +// the same judgement path an application gets. +const onomancy = await import("@inkandswitch/onomancy"); +const moduleWith = (resolveHostname) => ({ ...onomancy, resolveHostname }); + const noopRuntime = () => - createOnomancyRuntime({ - resolveHostname: async () => ({ records: [] }), - Name: class { - constructor() {} - }, - }); + createOnomancyRuntime(moduleWith(async () => ({ records: [] }))); test("publish strips every verification decoration, nothing else", async () => { let published; @@ -68,7 +75,8 @@ test("two subscriptions sharing one callback survive one unsubscribe", async () notice: "", lookup: () => undefined, list: () => [], - // An identity-deduplicating base, like the demo's localDirectory. + // An identity-deduplicating base: a directory that dedupes listeners + // by identity would collapse two subscriptions sharing one callback. subscribe: (fn) => { baseListeners.add(fn); return () => baseListeners.delete(fn); @@ -95,3 +103,100 @@ test("two subscriptions sharing one callback survive one unsubscribe", async () for (const fn of baseListeners) fn(); assert.equal(hits, 1, "the surviving subscription still fires"); }); + +test("contests a rotation tie: same document, different generation keys", async () => { + // Selection keyed on the document alone reads this as agreeing duplicates + // and picks a generation arbitrarily; the reference verifier refuses it. + // The rule now lives in the module's classifyRecords — the pin here is + // that the runtime's mapping carries the contest through: the flag set, + // the one document still reported. + const A = "aa".repeat(32); + const b64 = (h) => Buffer.from(h, "hex").toString("base64"); + const rec = (g) => `v=ONO0;k=ed25519;n=5;g=${g};p=${b64(A)}`; + const runtime = createOnomancyRuntime( + moduleWith(async () => ({ + records: [rec(b64("11".repeat(32))), rec(b64("22".repeat(32)))], + })), + { now: () => 1788000000000 } + ); + + const binding = await runtime.resolveBoundIds("a.example"); + assert.equal(binding.contested, true, "rotation tie must be contested"); + assert.equal(binding.ids.length, 1, "one document, still reported"); + assert.equal(binding.ids[0], A, "and it is the document, in hex"); +}); + +// The parser-rule pins that used to live here — fill-fixture point +// validity, the x=0 sign-bit edge, non-canonical base64 spellings — moved +// with the parser itself: the rules are onomancy's (`TxtRecord`, tested in +// Rust and replayed from the shared vectors in conformance-vectors.test.mjs), +// and this library holds no copy left to pin. + +// Local runs can test a stale dist; CI rebuilds first. Warn, do not fail. +{ + const newest = (dir) => { + let max = 0; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = dir + "/" + e.name; + max = Math.max(max, e.isDirectory() ? newest(p) : fs.statSync(p).mtimeMs); + } + return max; + }; + const src = new URL("../src", import.meta.url).pathname; + const dist = new URL("../dist/index.js", import.meta.url).pathname; + if (newest(src) > fs.statSync(dist).mtimeMs) { + console.warn( + "WARN: dist/ is older than src/ - run pnpm build before trusting these results" + ); + } +} + +test("a contested serial does not enter the ratchet", async () => { + // The ratchet remembers the highest serial ACCEPTED. Pin: contested@10 + // (refused), then the zone heals to a single record at the SAME serial on + // a stale chain - must verify, not read as replayed. + const { + createOnomancyDirectory, + createOnomancyRuntime, + createVerificationCache, + clearVerificationCache, + } = await import("../dist/onomancy/index.js"); + const lib = await import("../dist/index.js"); + const A = "aa".repeat(32); + const b64h = (h) => Buffer.from(h, "hex").toString("base64"); + const G1 = Buffer.from(new Uint8Array(32).fill(1)).toString("base64"); + const G2 = Buffer.from(new Uint8Array(32).fill(6)).toString("base64"); + const rec = (g) => `v=ONO0;k=ed25519;n=10;g=${g};p=${b64h(A)}`; + let script = [ + { records: [rec(G1), rec(G2)], freshness: "stale" }, // contested @10 + { records: [rec(G1)], freshness: "stale" }, // healed @10, stale chain + ]; + const rt = createOnomancyRuntime( + moduleWith(async () => script.shift()), + { now: () => 1788000000000 } + ); + const cache = createVerificationCache(); + const base = lib.createAutomergeDocDirectory( + { [A]: { name: "A", dnsName: "a.example" } }, + undefined + ); + const d = createOnomancyDirectory(base, rt, { + designation: (await import("../dist/onomancy/index.js")) + .idEqualityDesignation, + cache, + }); + const settle = async () => { + for (let i = 0; i < 6; i++) { + d.lookup(A); + await new Promise((r) => setTimeout(r, 25)); + } + return d.lookup(A).dnsNameStatus; + }; + assert.equal(await settle(), "contested"); + clearVerificationCache(cache); + assert.equal( + await settle(), + "verified", + "healed same-serial zone must not read replayed" + ); +});