From 2686c891815cd0f1796edd5f9143378f3c70d130 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:18 -0700 Subject: [PATCH 01/16] Add Nix flake Pins the toolchain (node, pnpm, playwright-from-nixpkgs) so the checks run identically across machines. Playwright is deliberately taken from nixpkgs rather than npm so the browser binaries match the driver. --- flake.lock | 141 +++++++++++++++++++++++++++++++++++++++++ flake.nix | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..848babd --- /dev/null +++ b/flake.lock @@ -0,0 +1,141 @@ +{ + "nodes": { + "command-utils": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1780272363, + "narHash": "sha256-kNxu1wkCevxc2SASJ+QXCoOcEWz+On6tR+lYv/ZWMpo=", + "ref": "refs/heads/main", + "rev": "eba91ae4f4a48eedab31777ed817d3c593cee7a8", + "revCount": 6, + "type": "git", + "url": "https://tangled.org/expede.wtf/nix-command-utils" + }, + "original": { + "type": "git", + "url": "https://tangled.org/expede.wtf/nix-command-utils" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "id": "flake-utils", + "type": "indirect" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixos-unstable": { + "locked": { + "lastModified": 1787755763, + "narHash": "sha256-+RQZpROPzC0RbT5n+jNgCiksRZAi+B8GxNAkKzbFlMQ=", + "rev": "eb0b16891e841575f77c31e3698eb047f55c25d8", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/unstable-small/nixos-26.11pre1062521.eb0b16891e84/nixexprs.tar.xz" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable-small", + "type": "indirect" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780203844, + "narHash": "sha256-K5sT4jTpGs15ADhviMKNBH38REpPf5Q6mM1+N6cArVE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b51242d7d43689db2f3be91bd05d5b24fbb469c4", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-26.05", + "type": "indirect" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1787753485, + "narHash": "sha256-mGPB6ofYpLaHyCO7tI0L1hCb69lsY0t7dqIsYIhXP7Y=", + "rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.8477.062346a6d85b/nixexprs.tar.xz" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-26.05", + "type": "indirect" + } + }, + "root": { + "inputs": { + "command-utils": "command-utils", + "flake-utils": "flake-utils_2", + "nixos-unstable": "nixos-unstable", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..697bde9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,180 @@ +{ + description = "keyhive-react"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-26.05"; + nixos-unstable.url = "nixpkgs/nixos-unstable-small"; + + command-utils.url = "git+https://tangled.org/expede.wtf/nix-command-utils"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + command-utils, + flake-utils, + nixos-unstable, + nixpkgs, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs {inherit system;}; + unstable = import nixos-unstable {inherit system;}; + + nodejs = pkgs.nodejs_22; + + # Pinned to pnpm 10 to match `packageManager` in package.json; + # pnpm 11 stopped reading `pnpm.overrides` from package.json and + # treats ignored build scripts as a hard error. + pnpm = pkgs.pnpm_10; + + # The driver version must match `@playwright/test` in package.json, + # otherwise the browser revisions in PLAYWRIGHT_BROWSERS_PATH won't + # resolve. + playwright = unstable.playwright-driver; + + format-pkgs = with pkgs; [ + alejandra + nixpkgs-fmt + ]; + + js-env = [nodejs pnpm]; + + mkCheck = name: text: + pkgs.writeShellApplication { + name = "keyhive-react-${name}"; + runtimeInputs = js-env; + text = '' + set -x + ${text} + ''; + }; + + # Mirrors .github/workflows/ci.yml; each check assumes + # `pnpm install --frozen-lockfile` has already run (the `ci` + # aggregate does it for you). + ci-checks = { + ci-lint = mkCheck "ci-lint" '' + pnpm run lint + ''; + + ci-tsc = mkCheck "ci-tsc" '' + pnpm run tsc + pnpm run tsc:e2e + ''; + + # Also runs check:isolation and check:prefix: the built output + # imports nothing but React, and every Tailwind class is prefixed + # so it cannot collide with the host application's styles. + ci-build = mkCheck "ci-build" '' + pnpm run build + ''; + + # Fails if the published tarball would be missing an entry point. + ci-pack = mkCheck "ci-pack" '' + npm pack --dry-run + ''; + + # A consumer compiled against the build above. + ci-app = mkCheck "ci-app" '' + pnpm run app:build + ''; + }; + + ci-all = pkgs.writeShellApplication { + name = "keyhive-react-ci"; + runtimeInputs = js-env ++ pkgs.lib.attrValues ci-checks; + text = '' + pnpm install --frozen-lockfile + ${pkgs.lib.concatMapStringsSep "\n" + (check: "keyhive-react-${check}") + (builtins.attrNames ci-checks)} + ''; + }; + + # Playwright tests driving the component test app: two browser + # contexts are two keyhive identities. Not in the `ci` aggregate: + # pulls whole browsers — run deliberately. + ci-e2e = pkgs.writeShellApplication { + name = "keyhive-react-ci-e2e"; + runtimeInputs = js-env; + text = '' + export PLAYWRIGHT_BROWSERS_PATH="${playwright.browsers}" + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + pnpm run test:e2e "$@" + ''; + }; + + cmd = command-utils.cmd.${system}; + pnpm' = command-utils.pnpm.${system}; + + command_menu = command-utils.commands.${system} [ + (pnpm'.build {pnpm = "${pnpm}/bin/pnpm";}) + (pnpm'.install {pnpm = "${pnpm}/bin/pnpm";}) + + (command-utils.asModule.${system} { + "lint" = cmd "ESLint + Prettier check" '' + exec ${pnpm}/bin/pnpm run lint + ''; + + "lint:fix" = cmd "Apply ESLint + Prettier fixes" '' + exec ${pnpm}/bin/pnpm run lint:fix + ''; + + "app:dev" = cmd "Run the component test app dev server" '' + exec ${pnpm}/bin/pnpm run app + ''; + + "test:e2e" = cmd "Playwright tests against the test app (extra args pass through)" '' + exec ${ci-e2e}/bin/keyhive-react-ci-e2e "$@" + ''; + + "test:e2e:ui" = cmd "Playwright tests in UI mode" '' + export PLAYWRIGHT_BROWSERS_PATH="${playwright.browsers}" + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + exec ${pnpm}/bin/pnpm run test:e2e:ui + ''; + + "ci" = cmd "Run all cheap CI checks (lint, tsc, build, pack, app)" '' + exec ${ci-all}/bin/keyhive-react-ci + ''; + }) + ]; + in { + devShells.default = pkgs.mkShell { + name = "keyhive-react_shell"; + + nativeBuildInputs = + command_menu + ++ js-env + ++ [ + pkgs.typescript + pkgs.typescript-language-server + ] + ++ format-pkgs; + + PLAYWRIGHT_BROWSERS_PATH = "${playwright.browsers}"; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + + shellHook = '' + unset SOURCE_DATE_EPOCH + export WORKSPACE_ROOT="$(pwd)" + menu + ''; + }; + + apps = + pkgs.lib.mapAttrs (name: check: { + type = "app"; + program = "${check}/bin/keyhive-react-${name}"; + }) + (ci-checks + // { + ci = ci-all; + ci-e2e = ci-e2e; + }); + + formatter = pkgs.alejandra; + } + ); +} From bec03f22c9fcead702f70124a97237528e27e3bc Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:29 -0700 Subject: [PATCH 02/16] Add DNS name verification through onomancy Directory entries may claim a DNS name; a verifying directory wrapper resolves the claim through onomancy (DNSSEC from the IANA root) and decorates entries with a status the components render. The library keeps the vocabulary and sheds the mechanism: it knows what a claim is and what the statuses mean, and resolves nothing itself. The onomancy Wasm arrives by injection, so the host application owns the only instance. Includes the DnsNameBadge, the claim field on ProfileEditor, a deterministic hostname-split stub for the test app, and Playwright e2e coverage. --- .gitignore | 2 + .prettierignore | 1 + README.md | 68 ++- apps/component-test-app/package.json | 1 + apps/component-test-app/src/App.tsx | 513 +++++++++++++++++- .../src/composeDirectories.ts | 65 +++ apps/component-test-app/src/localDirectory.ts | 7 + apps/component-test-app/src/nameResolution.ts | 212 ++++++++ apps/component-test-app/src/onomancyStub.ts | 53 ++ apps/component-test-app/vite.config.ts | 1 + e2e/dns-names.spec.ts | 73 +++ e2e/names.spec.ts | 151 ++++++ e2e/shared-directory.spec.ts | 80 +++ e2e/smoke.spec.ts | 4 +- package.json | 8 +- playwright.config.ts | 6 +- pnpm-lock.yaml | 52 +- scripts/check-prefix.mjs | 1 + src/components/AccessEditor.tsx | 11 +- src/components/AccountView.tsx | 8 + src/components/ContactBook.tsx | 10 + src/components/ProfileEditor.tsx | 63 +++ src/components/primitives/DnsNameBadge.tsx | 64 +++ src/directory/automerge-directory.ts | 19 +- src/directory/types.ts | 27 + src/index.ts | 32 +- src/onomancy/designation.ts | 94 ++++ src/onomancy/runtime.ts | 126 +++++ src/onomancy/useOnomancyDirectory.ts | 23 + src/onomancy/verified-directory.ts | 184 +++++++ src/runtime.ts | 4 + 31 files changed, 1933 insertions(+), 30 deletions(-) create mode 100644 apps/component-test-app/src/composeDirectories.ts create mode 100644 apps/component-test-app/src/nameResolution.ts create mode 100644 apps/component-test-app/src/onomancyStub.ts create mode 100644 e2e/dns-names.spec.ts create mode 100644 e2e/names.spec.ts create mode 100644 e2e/shared-directory.spec.ts create mode 100644 src/components/primitives/DnsNameBadge.tsx create mode 100644 src/onomancy/designation.ts create mode 100644 src/onomancy/runtime.ts create mode 100644 src/onomancy/useOnomancyDirectory.ts create mode 100644 src/onomancy/verified-directory.ts diff --git a/.gitignore b/.gitignore index f5c1980..0e237c8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist test-results playwright-report blob-report +.pnpm-store +.ignore diff --git a/.prettierignore b/.prettierignore index dee70d2..bc3946c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ dist pnpm-lock.yaml +.pnpm-store diff --git a/README.md b/README.md index 7052b14..cb3f2de 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ pnpm add @automerge/keyhive-react `@automerge/automerge-repo-keyhive`, `@automerge/react` and `react` are peer dependencies. The package imports none of them at runtime (see [The keyhive runtime](#the-keyhive-runtime)), so the application's copy is the -only one loaded. +only one loaded. `@inkandswitch/onomancy` is an optional peer dependency, +supplied the same way, for [DNS names](#dns-names). ## What is in it @@ -22,6 +23,7 @@ only one loaded. | `AccountView` | Display name, avatar, and the local contact card | | `AccessEditor` | Adding and removing members on a document or a group | | `DirectoryProvider` | Putting a name directory in scope | +| `DnsNameBadge` | A claimed DNS name with its verification state | ## Using it @@ -93,6 +95,70 @@ A directory declares what it cannot do: `writable`, `createAutomergeDocDirectory` covers a shared Automerge map document that each peer writes its own entry into. +## DNS names + +An entry can claim a DNS name (`entry.dnsName`), giving an identity a +memorable, globally shareable spelling like `@expede.wtf`. The claim is +self-asserted until it is verified through +[onomancy](https://github.com/inkandswitch/onomancy): the domain publishes a +DNSSEC-protected `_onomancy` TXT record whose `p=` field is the identity's +ed25519 verifying key, and the record is validated locally from the IANA root +— no registry, no certificate authority, and no trust in whoever relayed it. + +Like keyhive, onomancy is Wasm-backed, so the application supplies its own +copy through a runtime and this package imports nothing: + +```tsx +import * as onomancy from "@inkandswitch/onomancy"; +import { + createOnomancyRuntime, + useOnomancyDirectory, +} from "@automerge/keyhive-react"; + +const onomancyRuntime = createOnomancyRuntime(onomancy); + +function App({ baseDirectory }) { + // Decorates entries that claim a dnsName with a verification status. + const directory = useOnomancyDirectory(baseDirectory, onomancyRuntime); + return {/* … */}; +} +``` + +A claim is checked once, lazily, the first time its entry is read, and the +result lands on the entry as `dnsNameStatus`: `verified`, `mismatch`, +`unreachable`, `unsynced`, `pending`, or `invalid`. `ContactBook`, +`AccessEditor`, and `ProfileEditor` render the claim as a `DnsNameBadge`; a +directory without the wrapper renders claims as exactly that — claims, +visually no stronger than a self-asserted display name. + +Verification is two layers. DNS proves `hostname → root document ids`; a +_designation_ decides whether those documents belong to the entry's identity. +The default designation requires the bound id to be the identity itself — the +solo case. Domains are meant to bind a shared root namestore document instead, +whose admins own the name (ownership is shared by inviting more admins; the +DNS record never changes): + +```tsx +const designation = createKeyhiveDesignation(keyhiveRuntime, hive); +const directory = useOnomancyDirectory(baseDirectory, onomancyRuntime, { + designation, +}); +``` + +The keyhive designation accepts both anchor shapes: a bound id that is the +identity verifies directly, and otherwise the designated document's members +are consulted (admin access by default). A designated document this device has +not synced reads `unsynced` — not evidence either way — until a replica +arrives. + +`AccountView` offers the field for claiming a name (turn it off with +`showDnsName={false}`). Publishing an empty string withdraws the claim. + +A verified badge proves that the domain, as attested by a DNSSEC chain from +the IANA root during the chain's signature window, designated this identity. +It proves nothing about the domain owner's intentions, and nothing about any +other name. + ## Styling ```ts diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index 13595f1..2102ab2 100644 --- a/apps/component-test-app/package.json +++ b/apps/component-test-app/package.json @@ -16,6 +16,7 @@ "@automerge/automerge-subduction": "0.16.1", "@automerge/keyhive-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": "0.1.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 bddd4f9..5e09908 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -1,5 +1,16 @@ -import { useCallback, useMemo, useState } from "react"; -import type { AutomergeUrl, Repo } from "@automerge/react/slim"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import { + isValidAutomergeUrl, + useDocument, + type AutomergeUrl, + type Repo, +} from "@automerge/react/slim"; import type { AutomergeRepoKeyhive, Group, @@ -7,17 +18,52 @@ import type { import { AccountView, bytesToHex, + CopyableField, createDocumentTarget, createGroupTarget, + createKeyhiveDesignation, + createOnomancyRuntime, DirectoryProvider, + idEqualityDesignation, + type DirectoryDoc, + type DnsDesignation, + type NameDirectory, AccessEditor, ProfileEditor, + useAutomergeDocDirectory, useKeyhiveUpdates, + useOnomancyDirectory, } from "@automerge/keyhive-react"; +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"; +const DIRECTORY_URL_KEY = "keyhive-test-app-directory-url"; +/** "auto" when this profile created the directory, "loaded" when pasted in. */ +const DIRECTORY_ORIGIN_KEY = "keyhive-test-app-directory-origin"; + +function storedDirectoryUrl(): AutomergeUrl | null { + const raw = localStorage.getItem(DIRECTORY_URL_KEY); + return raw && isValidAutomergeUrl(raw) ? raw : null; +} + +function base64FromHex(hex: string): string { + let binary = ""; + for (let i = 0; i < hex.length; i += 2) { + binary += String.fromCharCode(Number.parseInt(hex.slice(i, i + 2), 16)); + } + return btoa(binary); +} + interface AppProps { hive: AutomergeRepoKeyhive; repo: Repo; @@ -27,17 +73,208 @@ interface AppProps { * A test app for the keyhive-react components. */ export default function App({ hive, repo }: AppProps) { - // Built once. This directory holds its own listeners. - const directory = useMemo(() => createLocalDirectory(), []); + // 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. + const [directoryUrl, setDirectoryUrl] = useState( + storedDirectoryUrl + ); + + useEffect(() => { + 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: {} }); + await hive.addSyncServerRelayToDoc(handle.url); + if (!cancelled) { + localStorage.setItem(DIRECTORY_URL_KEY, handle.url); + localStorage.setItem(DIRECTORY_ORIGIN_KEY, "auto"); + setDirectoryUrl(handle.url); + } + })(); + return () => { + cancelled = true; + }; + }, [directoryUrl, hive, repo]); + + const loadDirectory = useCallback((url: AutomergeUrl) => { + localStorage.setItem(DIRECTORY_URL_KEY, url); + localStorage.setItem(DIRECTORY_ORIGIN_KEY, "loaded"); + setDirectoryUrl(url); + }, []); + + // Re-render as the directory document loads: useDocument alone can miss + // the handle becoming ready (DocumentPanel leans on the same nudge via + // useReRenderOnDocProgress, which requires a non-null url). + const directoryProgress = useMemo( + () => (directoryUrl ? repo.findWithProgress(directoryUrl) : null), + [repo, directoryUrl] + ); + useSyncExternalStore( + (onChange) => + directoryProgress ? directoryProgress.subscribe(onChange) : () => {}, + () => (directoryProgress ? directoryProgress.peek().state : "none") + ); + + const [directoryDoc, changeDirectoryDoc] = useDocument( + directoryUrl ?? undefined, + { suspense: false } + ); + + // Self-heal: an auto-created directory that never becomes ready is a dead + // document (an earlier build created them empty, and empty documents never + // load in the current stack). Recreate it. Manually loaded directories are + // left alone: theirs is ordinary sync latency. + useEffect(() => { + if (!directoryUrl || directoryDoc) return; + // A missing origin predates the marker: those urls were, at best, + // auto-created empty documents. Only "loaded" is protected. + if (localStorage.getItem(DIRECTORY_ORIGIN_KEY) === "loaded") return; + const timer = setTimeout(() => { + localStorage.removeItem(DIRECTORY_URL_KEY); + setDirectoryUrl(null); + }, 10_000); + return () => clearTimeout(timer); + }, [directoryUrl, directoryDoc]); + const docDirectory = useAutomergeDocDirectory( + directoryDoc, + directoryDoc ? changeDirectoryDoc : undefined, + { + source: "directory-doc", + notice: + "Names are shared through a directory document and sync between profiles that can read it.", + } + ); + + // Reads prefer the shared document, writes go to both. + const directory: NameDirectory = useMemo( + () => + directoryDoc + ? composeDirectories(docDirectory, localDirectory) + : localDirectory, + [directoryDoc, docDirectory, localDirectory] + ); + + // A real app imports @inkandswitch/onomancy here instead of the stub. + const onomancyRuntime = useMemo( + () => + createOnomancyRuntime( + createStubOnomancy(bytesToHex(hive.active.individual.id.toBytes())) + ), + [hive] + ); + // Domains may bind the identity directly or a shared root document whose + // admins own the name; the keyhive designation accepts both. The stub's + // `.test` names stay on plain id equality so the e2e outcomes are + // deterministic without any keyhive documents behind them. + const designation = useMemo(() => { + const keyhiveDesignation = createKeyhiveDesignation(keyhiveRuntime, hive); + const compose: DnsDesignation = (entry, boundIds, hostname) => + hostname.endsWith(".test") + ? idEqualityDesignation(entry, boundIds, hostname) + : keyhiveDesignation(entry, boundIds, hostname); + return compose; + }, [hive]); + const verifyingDirectory = useOnomancyDirectory(directory, onomancyRuntime, { + 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. + const bindName = useCallback( + async (rawPath: string, url: AutomergeUrl): Promise => { + const { root, segments } = parseLookup(rawPath); + checkSegments(segments); + if (segments.length === 0) { + throw new Error("Nothing to bind: add at least one path segment."); + } + const key = segments.join("/"); + + let targetUrl: AutomergeUrl; + let spelling: string; + if (root === "self") { + if (!directoryUrl) throw new Error("No directory document yet."); + targetUrl = directoryUrl; + spelling = `~/${key}`; + } else if ("hostname" in root) { + targetUrl = await hostnameRoot(onomancyRuntime, root.hostname); + spelling = `@${root.hostname}/${key}`; + } else { + targetUrl = root.url; + spelling = `${root.url}/${key}`; + } + + let handle; + try { + handle = await repo.find< + DirectoryDoc & { onomancy?: Record } + >(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; + }); + return spelling; + }, + [directoryUrl, onomancyRuntime, repo] + ); + + const resolveName = useCallback( + (raw: string): Promise => { + if (!directoryUrl) return Promise.reject(new Error("No directory yet.")); + return resolveLookup(repo, onomancyRuntime, directoryUrl, raw); + }, + [repo, onomancyRuntime, directoryUrl] + ); return ( - - + + ); } -function TestApp({ hive, repo }: AppProps) { +interface TestAppProps extends AppProps { + directoryUrl: AutomergeUrl | null; + onLoadDirectory: (url: AutomergeUrl) => void; + onBindName: (path: string, url: AutomergeUrl) => Promise; + onResolveName: (raw: string) => Promise; + bindReady: boolean; +} + +function TestApp({ + hive, + repo, + directoryUrl, + onLoadDirectory, + onBindName, + onResolveName, + bindReady, +}: TestAppProps) { const keyhiveVersion = useKeyhiveUpdates(hive); const [docUrl, setDocUrl] = useState(null); const [group, setGroup] = useState(null); @@ -77,6 +314,25 @@ function TestApp({ hive, repo }: AppProps) { () => (group ? createGroupTarget(keyhiveRuntime, hive, group) : null), [hive, group] ); + const directoryTarget = useMemo( + () => + directoryUrl + ? createDocumentTarget(keyhiveRuntime, hive, directoryUrl) + : null, + [hive, directoryUrl] + ); + + // The ready-to-publish TXT binding record: p= is the directory document + // (the root doc a domain designates), g= is this identity's key (the + // delegation-chain chokepoint; this identity is the doc's first admin). + const dnsRecord = useMemo(() => { + if (!directoryUrl) return null; + const docIdHex = bytesToHex( + keyhiveRuntime.docIdFromAutomergeUrl(directoryUrl).toBytes() + ); + const selfHex = bytesToHex(hive.active.individual.id.toBytes()); + return `v=ONO0;k=ed25519;n=${Date.now()};g=${base64FromHex(selfHex)};p=${base64FromHex(docIdHex)}`; + }, [directoryUrl, hive]); const addGroupToDocument = useCallback(async () => { setError(null); @@ -109,12 +365,61 @@ function TestApp({ hive, repo }: AppProps) {

Account

- Names are written to a localStorage directory rather than a shared - document. + Names are written to the shared directory document below, with a local + copy kept in this browser.

+
+

Name directory

+

+ The shared document names live in. It is also the root document a + domain can designate: publish the DNS record below, and every admin of + this directory verifies as the domain. +

+ {directoryUrl ? ( + <> + + {dnsRecord && ( + + )} + + + ) : ( +

Creating a directory document…

+ )} + +
+ +
+

Names

+

+ Bind a path to a document, then look documents up by name. The anchor + picks the namestore: ~/pics (or bare pics) + is this directory, @example.com/pics is whatever root + document that domain designates, and automerge:…/pics is + that document itself. After the anchor, every name walks the same way. +

+ +
+

Document

{docUrl ? ( @@ -176,3 +481,193 @@ function TestApp({ hive, repo }: AppProps) { ); } + +interface NamesSectionProps { + /** Resolves to the canonical spelling of the bound edge. */ + onBind: (path: string, url: AutomergeUrl) => Promise; + onResolve: (raw: string) => Promise; + onOpen: (url: AutomergeUrl) => void; + /** False until the directory document is loaded and writable. */ + bindReady: boolean; +} + +/** Bind namestore edges and resolve names against them. */ +function NamesSection({ + onBind, + onResolve, + onOpen, + bindReady, +}: NamesSectionProps) { + const [bindPath, setBindPath] = useState(""); + const [bindUrl, setBindUrl] = useState(""); + const [bindError, setBindError] = useState(null); + const [bound, setBound] = useState(null); + + const [query, setQuery] = useState(""); + const [outcome, setOutcome] = useState(null); + const [resolveError, setResolveError] = useState(null); + const [resolving, setResolving] = useState(false); + + return ( + <> +
{ + e.preventDefault(); + setBindError(null); + setBound(null); + const path = bindPath.trim(); + const raw = bindUrl.trim(); + const url = raw.startsWith("automerge:") ? raw : `automerge:${raw}`; + if (!path) return; + if (!isValidAutomergeUrl(url)) { + setBindError("That is not a valid Automerge document id."); + return; + } + onBind(path, url) + .then((spelling) => { + setBound(spelling); + setBindPath(""); + setBindUrl(""); + }) + .catch((err: unknown) => { + setBindError(err instanceof Error ? err.message : String(err)); + }); + }} + style={{ display: "flex", gap: "0.5rem" }} + > + setBindPath(e.target.value)} + placeholder="pics/vacation" + aria-label="Name path" + style={{ flex: 1, padding: "0.5rem", font: "inherit" }} + /> + setBindUrl(e.target.value)} + placeholder="automerge:…" + aria-label="Named document id" + style={{ flex: 2, padding: "0.5rem", font: "inherit" }} + /> + +
+ {bindError && ( +

+ {bindError} +

+ )} + {bound &&

Bound {bound}.

} + +
{ + e.preventDefault(); + const raw = query.trim(); + if (!raw) return; + setResolveError(null); + setOutcome(null); + setResolving(true); + onResolve(raw) + .then(setOutcome) + .catch((err: unknown) => { + setResolveError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setResolving(false)); + }} + style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }} + > + setQuery(e.target.value)} + placeholder="~/pics/vacation or @example.com/pics" + aria-label="Name to resolve" + style={{ flex: 1, padding: "0.5rem", font: "inherit" }} + /> + +
+ {resolveError && ( +

+ {resolveError} +

+ )} + {outcome?.status === "resolved" && ( +

+ Resolved to {outcome.url}{" "} + +

+ )} + {outcome?.status === "partial" && ( +

+ Partial: consumed {outcome.consumed} of {outcome.total} segment(s), + then{" "} + {outcome.reason === "unsynced-target" + ? "the next document is not synced here:" + : "no edge matched the remaining segments in:"}{" "} + {outcome.at} + {outcome.reason === "unsynced-target" && outcome.consumed === 0 && ( + <> + {" "} + If this is your own domain, compare it with the Directory id + above: an older published record may designate a document that no + longer exists. + + )} +

+ )} + + ); +} + +interface LoadDirectoryProps { + onLoad: (url: AutomergeUrl) => void; +} + +/** Switch to a directory document another profile shared. */ +function LoadDirectory({ onLoad }: LoadDirectoryProps) { + const [input, setInput] = useState(""); + const [loadError, setLoadError] = useState(null); + + return ( +
{ + e.preventDefault(); + const trimmed = input.trim(); + if (!trimmed) return; + const url = trimmed.startsWith("automerge:") + ? trimmed + : `automerge:${trimmed}`; + if (!isValidAutomergeUrl(url)) { + setLoadError("That is not a valid Automerge document id."); + return; + } + setLoadError(null); + setInput(""); + onLoad(url); + }} + style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }} + > + setInput(e.target.value)} + placeholder="Directory id" + aria-label="Load directory id" + style={{ flex: 1, padding: "0.5rem", font: "inherit" }} + /> + + {loadError && ( +

+ {loadError} +

+ )} +
+ ); +} diff --git a/apps/component-test-app/src/composeDirectories.ts b/apps/component-test-app/src/composeDirectories.ts new file mode 100644 index 0000000..9603e20 --- /dev/null +++ b/apps/component-test-app/src/composeDirectories.ts @@ -0,0 +1,65 @@ +import type { DirectoryEntry, NameDirectory } from "@automerge/keyhive-react"; + +function definedFields(entry: DirectoryEntry): DirectoryEntry { + const out = { ...entry }; + for (const key of Object.keys(out) as (keyof DirectoryEntry)[]) { + if (out[key] === undefined) delete out[key]; + } + return out; +} + +function mergeEntries( + primary: DirectoryEntry | undefined, + fallback: DirectoryEntry | undefined +): DirectoryEntry | undefined { + if (!primary) return fallback; + if (!fallback) return primary; + return { ...fallback, ...definedFields(primary) }; +} + +/** + * 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. + */ +export function composeDirectories( + primary: NameDirectory, + fallback: NameDirectory +): NameDirectory { + return { + source: `${primary.source}+${fallback.source}`, + trust: "unverified", + writable: primary.writable || fallback.writable, + enumerable: true, + notice: primary.notice ?? fallback.notice, + + lookup(id) { + return mergeEntries(primary.lookup(id), fallback.lookup(id)); + }, + + list() { + const byId = new Map(); + for (const entry of fallback.list()) byId.set(entry.id, entry); + for (const entry of primary.list()) { + const merged = mergeEntries(entry, byId.get(entry.id)); + if (merged) byId.set(entry.id, merged); + } + return [...byId.values()]; + }, + + async publish(entry) { + if (primary.publish) await primary.publish(entry); + if (fallback.publish) await fallback.publish(entry); + }, + + subscribe(listener) { + const subscriptions = [ + primary.subscribe?.(listener), + fallback.subscribe?.(listener), + ]; + return () => { + for (const unsubscribe of subscriptions) unsubscribe?.(); + }; + }, + }; +} diff --git a/apps/component-test-app/src/localDirectory.ts b/apps/component-test-app/src/localDirectory.ts index 0057f87..e7ee2b5 100644 --- a/apps/component-test-app/src/localDirectory.ts +++ b/apps/component-test-app/src/localDirectory.ts @@ -18,6 +18,7 @@ interface StoredEntry { avatarBase64?: string; kind?: DirectoryEntryKind; contactCard?: string; + dnsName?: string; } type StoredDirectory = Record; @@ -68,6 +69,7 @@ export function createLocalDirectory(): NameDirectory { avatar: decodeAvatar(record.avatarBase64), kind: record.kind, contactCard: record.contactCard, + dnsName: record.dnsName, }; } @@ -112,6 +114,11 @@ export function createLocalDirectory(): NameDirectory { 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(); diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts new file mode 100644 index 0000000..282746f --- /dev/null +++ b/apps/component-test-app/src/nameResolution.ts @@ -0,0 +1,212 @@ +import { + isValidAutomergeUrl, + stringifyAutomergeUrl, + type AutomergeUrl, + type Repo, +} from "@automerge/react/slim"; +import { + hexToBytes, + RESERVED_ONOMANCY_KEY, + type OnomancyRuntime, +} from "@automerge/keyhive-react"; + +/** + * 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. + */ + +export type Resolution = + | { status: "resolved"; url: AutomergeUrl } + | { + status: "partial"; + consumed: number; + total: number; + reason: "unsynced-target" | "dangling-segment"; + /** The document the walk stopped at (hold or sync it and retry). */ + at: AutomergeUrl; + }; + +/** A parsed lookup: where the walk starts and the segments to consume. */ +export interface ParsedLookup { + root: "self" | { hostname: string } | { url: AutomergeUrl }; + segments: string[]; +} + +/** + * Parse a name into its anchor family and segments, per the name grammar's + * disjoint leading tokens: `~` (and, as a convenience, bare paths) root at + * our own directory document, `@hostname` at whatever the DNS binding + * designates, and `automerge:` at the document itself. Whatever the anchor, + * the walk after it is identical. + */ +export function parseLookup(raw: string): ParsedLookup { + let rest = raw.trim(); + const root: ParsedLookup["root"] = "self"; + + 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 }; + } + + if (rest.startsWith("automerge:")) { + const [anchor, ...segments] = rest.split("/"); + if (!isValidAutomergeUrl(anchor)) { + throw new Error(`Not a document anchor: "${anchor}"`); + } + return { root: { url: anchor }, segments }; + } + + 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. */ +export function urlFromDocIdHex(hex: string): AutomergeUrl { + return stringifyAutomergeUrl( + hexToBytes(hex) as Parameters[0] + ); +} + +/** 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, + url: AutomergeUrl +): Promise | undefined> { + let doc: unknown; + try { + const handle = await repo.find(url); + doc = handle.doc(); + } catch { + 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 {}; + + 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; + } + } + return edges; +} + +/** Greedy longest-key match: the most segments, at segment boundaries. */ +function longestMatch( + edges: Record, + segments: string[] +): { key: string; length: number } | undefined { + let best: { key: string; length: number } | undefined; + for (const key of Object.keys(edges)) { + const parts = key.split("/"); + if (parts.length > segments.length) continue; + if (!parts.every((part, i) => part === segments[i])) continue; + if (!best || parts.length > best.length) { + best = { key, length: parts.length }; + } + } + return best; +} + +/** Resolve segments from a root document. No backtracking, live reads. */ +export async function resolvePath( + repo: Repo, + root: AutomergeUrl, + segments: string[] +): Promise { + checkSegments(segments); + + let current = root; + let consumed = 0; + const total = segments.length; + let remaining = segments; + + while (remaining.length > 0) { + const edges = await namestoreOf(repo, current); + if (edges === undefined) { + return { + status: "partial", + consumed, + total, + reason: "unsynced-target", + at: current, + }; + } + + const match = longestMatch(edges, remaining); + if (!match) { + return { + status: "partial", + consumed, + total, + reason: "dangling-segment", + at: current, + }; + } + + current = edges[match.key]; + consumed += match.length; + remaining = remaining.slice(match.length); + } + + return { status: "resolved", url: current }; +} + +/** + * Resolve a full lookup. A `@hostname` root goes through the onomancy + * runtime (DNSSEC-validated TXT record) to the bound root document; `~` and + * bare paths start from our own directory document. + */ +export async function resolveLookup( + repo: Repo, + runtime: OnomancyRuntime, + selfRoot: AutomergeUrl, + raw: string +): Promise { + const { root, segments } = parseLookup(raw); + + const rootUrl = + root === "self" + ? selfRoot + : "url" in root + ? root.url + : await hostnameRoot(runtime, root.hostname); + return resolvePath(repo, rootUrl, segments); +} + +/** The root document a hostname's DNS binding designates. */ +export async function hostnameRoot( + runtime: OnomancyRuntime, + hostname: string +): Promise { + const binding = await runtime.resolveBoundIds(hostname); + const [boundId] = binding.ids; + if (boundId === undefined) { + throw new Error(`No usable onomancy binding for ${hostname}.`); + } + return urlFromDocIdHex(boundId); +} diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts new file mode 100644 index 0000000..12ffaa2 --- /dev/null +++ b/apps/component-test-app/src/onomancyStub.ts @@ -0,0 +1,53 @@ +import * as onomancy from "@inkandswitch/onomancy"; +import type { OnomancyModule } from "@automerge/keyhive-react"; + +/** + * Real onomancy for real domains; deterministic outcomes under `.test`, so + * the e2e tests need neither a network nor real domains. A real application + * imports the package and hands it to `createOnomancyRuntime` unchanged. + * + * - `self.test` resolves to the local identity: a claim of it verifies. + * - `other.test` resolves to a different identity: a claim of it mismatches. + * - Other `.test` names reject, as an unreachable or unbound domain would. + * - Everything else goes to `@inkandswitch/onomancy`: DoH plus DNSSEC + * validation from the IANA root, inside the Wasm. + */ +export function createStubOnomancy(selfIdHex: string): OnomancyModule { + return { + resolveHostname(hostname: string, dohUrl?: string | null) { + if (!hostname.endsWith(".test")) { + return onomancy.resolveHostname(hostname, dohUrl); + } + switch (hostname) { + case "self.test": + return Promise.resolve(outcome(hostname, selfIdHex)); + case "other.test": + return Promise.resolve(outcome(hostname, "ab".repeat(32))); + default: + return Promise.reject( + new Error(`No onomancy binding for ${hostname}`) + ); + } + }, + }; +} + +function outcome(hostname: string, boundIdHex: string) { + const generationKey = hexToBase64("00".repeat(32)); + return { + hostname, + links: [], + freshness: "fresh", + records: [ + `v=ONO0;k=ed25519;n=1;g=${generationKey};p=${hexToBase64(boundIdHex)}`, + ], + }; +} + +function hexToBase64(hex: string): string { + let binary = ""; + for (let i = 0; i < hex.length; i += 2) { + binary += String.fromCharCode(Number.parseInt(hex.slice(i, i + 2), 16)); + } + return btoa(binary); +} diff --git a/apps/component-test-app/vite.config.ts b/apps/component-test-app/vite.config.ts index b7cf859..bfbbfaa 100644 --- a/apps/component-test-app/vite.config.ts +++ b/apps/component-test-app/vite.config.ts @@ -82,6 +82,7 @@ export default defineConfig({ "@automerge/automerge-subduction", "@automerge/automerge-subduction/slim", "@automerge/automerge-repo-keyhive", + "@inkandswitch/onomancy", "@keyhive/keyhive", "@keyhive/keyhive/slim", ], diff --git a/e2e/dns-names.spec.ts b/e2e/dns-names.spec.ts new file mode 100644 index 0000000..195058f --- /dev/null +++ b/e2e/dns-names.spec.ts @@ -0,0 +1,73 @@ +import { expect, test, type Page } from "@playwright/test"; +import { openApp, section } from "./helpers"; + +// The test app's onomancy stub resolves `self.test` to the local identity, +// `other.test` to a different one, and rejects everything else. See +// apps/component-test-app/src/onomancyStub.ts. + +async function claimDnsName(page: Page, name: string): Promise { + const account = section(page, "Account"); + await account.getByRole("textbox", { name: "DNS name" }).fill(name); + await account.getByRole("button", { name: "Save" }).click(); +} + +function badge(page: Page, text: string) { + return section(page, "Account").locator("span", { hasText: text }).first(); +} + +test.describe("DNS names verified through onomancy", () => { + test("a claim of a domain bound to this identity verifies", async ({ + page, + }) => { + await openApp(page); + + await claimDnsName(page, "@self.test"); + const claimed = badge(page, "@self.test"); + await expect(claimed).toBeVisible(); + await expect(claimed).toHaveAttribute("title", /DNSSEC-verified/); + }); + + test("a claim of someone else's domain is marked a mismatch", async ({ + page, + }) => { + await openApp(page); + + await claimDnsName(page, "other.test"); + const claimed = badge(page, "@other.test"); + await expect(claimed).toBeVisible(); + await expect(claimed).toHaveAttribute( + "title", + /designates a different identity/ + ); + }); + + test("an unresolvable domain is marked unreachable, not failed", async ({ + page, + }) => { + await openApp(page); + + await claimDnsName(page, "nowhere.test"); + const claimed = badge(page, "@nowhere.test"); + await expect(claimed).toBeVisible(); + await expect(claimed).toHaveAttribute("title", /could not be resolved/); + }); + + test("a dotless name is rejected before it is stored", async ({ page }) => { + await openApp(page); + + await claimDnsName(page, "nodots"); + await expect(section(page, "Account").getByRole("alert")).toContainText( + "at least one dot" + ); + }); + + test("clearing the field withdraws the claim", async ({ page }) => { + await openApp(page); + + await claimDnsName(page, "self.test"); + await expect(badge(page, "@self.test")).toBeVisible(); + + await claimDnsName(page, ""); + await expect(badge(page, "@self.test")).not.toBeVisible(); + }); +}); diff --git a/e2e/names.spec.ts b/e2e/names.spec.ts new file mode 100644 index 0000000..77a85a7 --- /dev/null +++ b/e2e/names.spec.ts @@ -0,0 +1,151 @@ +import { expect, test, type Page } from "@playwright/test"; +import { copyableValue, createDocument, openApp, section } from "./helpers"; + +function names(page: Page) { + return section(page, "Names"); +} + +async function resolve(page: Page, name: string): Promise { + await names(page) + .getByRole("textbox", { name: "Name to resolve" }) + .fill(name); + await names(page).getByRole("button", { name: "Resolve" }).click(); +} + +/** + * Document writes persist on a storage debounce, so a reload immediately + * after a change can lose it — a race no interactive user hits. Give the + * flush room before reloading. + */ +async function settleThenReload(page: Page): Promise { + await page.waitForTimeout(2_000); + await page.reload(); + await openApp(page); + await expect(copyableValue(page, "Directory id")).not.toBeEmpty({ + timeout: 60_000, + }); +} + +test.describe("binding and resolving names", () => { + test("a bound path resolves and opens the document, across reloads", async ({ + page, + }) => { + await openApp(page); + await expect(copyableValue(page, "Directory id")).not.toBeEmpty({ + timeout: 60_000, + }); + + await createDocument(page); + const docUrl = ( + await copyableValue(page, "Document id").innerText() + ).trim(); + + await names(page) + .getByRole("textbox", { name: "Name path" }) + .fill("pics/vacation"); + await names(page) + .getByRole("textbox", { name: "Named document id" }) + .fill(docUrl); + await names(page).getByRole("button", { name: "Bind" }).click(); + await expect(names(page).getByText("Bound ~/pics/vacation.")).toBeVisible(); + + // Names are live document data: they survive a reload with no session + // state, which is the point of writing them into the directory. + await settleThenReload(page); + + await resolve(page, "~/pics/vacation"); + await expect(names(page).getByText(`Resolved to ${docUrl}`)).toBeVisible(); + + await names(page).getByRole("button", { name: "Open" }).click(); + await expect(copyableValue(page, "Document id")).toHaveText(docUrl); + + // The bare spelling resolves identically: the root is implied. + await resolve(page, "pics/vacation"); + await expect(names(page).getByText(`Resolved to ${docUrl}`)).toBeVisible(); + }); + + test("an unbound path is a partial walk, not an error", async ({ page }) => { + await openApp(page); + await expect(copyableValue(page, "Directory id")).not.toBeEmpty({ + timeout: 60_000, + }); + + await resolve(page, "~/nowhere"); + await expect( + names(page).getByText(/consumed 0 of 1 segment.*no edge matched/) + ).toBeVisible(); + }); + + test("a doc anchor addresses the same namestore as ~", async ({ page }) => { + await openApp(page); + const directoryId = copyableValue(page, "Directory id"); + await expect(directoryId).not.toBeEmpty({ timeout: 60_000 }); + const directoryUrl = (await directoryId.innerText()).trim(); + + await createDocument(page); + const docUrl = ( + await copyableValue(page, "Document id").innerText() + ).trim(); + + // Bind through the doc-anchor spelling of our own directory… + await names(page) + .getByRole("textbox", { name: "Name path" }) + .fill(`${directoryUrl}/direct`); + await names(page) + .getByRole("textbox", { name: "Named document id" }) + .fill(docUrl); + await names(page).getByRole("button", { name: "Bind" }).click(); + await expect( + names(page).getByText(`Bound ${directoryUrl}/direct.`) + ).toBeVisible(); + + // …and read it back through both spellings: same edge, same walk. + await resolve(page, "~/direct"); + await expect(names(page).getByText(`Resolved to ${docUrl}`)).toBeVisible(); + + await resolve(page, `${directoryUrl}/direct`); + await expect(names(page).getByText(`Resolved to ${docUrl}`)).toBeVisible(); + }); + + test("greedy matching prefers the longest key", async ({ page }) => { + await openApp(page); + await expect(copyableValue(page, "Directory id")).not.toBeEmpty({ + timeout: 60_000, + }); + + // Two edges, one a prefix of the other, bound to different documents. + await createDocument(page); + const shortDoc = ( + await copyableValue(page, "Document id").innerText() + ).trim(); + await names(page).getByRole("textbox", { name: "Name path" }).fill("pics"); + await names(page) + .getByRole("textbox", { name: "Named document id" }) + .fill(shortDoc); + await names(page).getByRole("button", { name: "Bind" }).click(); + await expect(names(page).getByText("Bound ~/pics.")).toBeVisible(); + + // A second document under the longer key. + await settleThenReload(page); + await createDocument(page); + const longDoc = ( + await copyableValue(page, "Document id").innerText() + ).trim(); + await names(page) + .getByRole("textbox", { name: "Name path" }) + .fill("pics/vacation"); + await names(page) + .getByRole("textbox", { name: "Named document id" }) + .fill(longDoc); + await names(page).getByRole("button", { name: "Bind" }).click(); + await expect(names(page).getByText("Bound ~/pics/vacation.")).toBeVisible(); + + await resolve(page, "~/pics/vacation"); + await expect(names(page).getByText(`Resolved to ${longDoc}`)).toBeVisible(); + + await resolve(page, "~/pics"); + await expect( + names(page).getByText(`Resolved to ${shortDoc}`) + ).toBeVisible(); + }); +}); diff --git a/e2e/shared-directory.spec.ts b/e2e/shared-directory.spec.ts new file mode 100644 index 0000000..9d74ee0 --- /dev/null +++ b/e2e/shared-directory.spec.ts @@ -0,0 +1,80 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + addMember, + contactCard, + copyableValue, + createDocument, + openApp, + openSecondIdentity, + section, +} from "./helpers"; + +// Names propagate through a real sync server, so give the round trips room. +test.setTimeout(180_000); + +async function saveName(page: Page, name: string): Promise { + const account = section(page, "Account"); + await account.getByRole("textbox", { name: "Name", exact: true }).fill(name); + await account.getByRole("button", { name: "Save" }).click(); +} + +/** The contact search inside the Document access editor. */ +function contactSearch(page: Page) { + return section(page, "Document access").getByRole("searchbox"); +} + +function contactResult(page: Page, name: string) { + return section(page, "Document access").getByRole("button", { + name: new RegExp(name), + }); +} + +test.describe("names shared through a directory document", () => { + // Keyhive delegations sync between profiles (the grant shows up on both + // sides), but Automerge document CONTENTS do not currently arrive at a + // second profile — the pre-existing Document section has the same gap + // ("Loading the document…" forever, access Read). Un-skip when + // cross-profile document sync works in the underlying stack. + test.fixme("two identities see each other's names after sharing one directory", async ({ + page, + browser, + }) => { + await openApp(page); + + // The directory document is created on first run; names written before + // it exists would land only in localStorage. + const directoryId = copyableValue(page, "Directory id"); + await expect(directoryId).not.toBeEmpty({ timeout: 60_000 }); + await saveName(page, "Alice"); + + const other = await openSecondIdentity(browser); + const theirCard = await contactCard(other.page).innerText(); + + // Writing into the directory is delegation like any other document. + await addMember(page, "Name directory", theirCard, "EDIT"); + + const url = (await directoryId.innerText()).trim(); + const otherDirectory = section(other.page, "Name directory"); + await otherDirectory + .getByRole("textbox", { name: "Load directory id" }) + .fill(url); + await otherDirectory.getByRole("button", { name: "Load" }).click(); + + // Alice's name arriving is the signal that the directory has synced. + await createDocument(other.page); + await contactSearch(other.page).fill("Alice"); + await expect(contactResult(other.page, "Alice")).toBeVisible({ + timeout: 60_000, + }); + + // The other identity's name travels back. + await saveName(other.page, "Bob"); + await createDocument(page); + await contactSearch(page).fill("Bob"); + await expect(contactResult(page, "Bob")).toBeVisible({ + timeout: 60_000, + }); + + await other.context.close(); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 7ab9fb0..4b58373 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -7,7 +7,9 @@ test.describe("the library renders in a host application", () => { const account = section(page, "Account"); // ProfileEditor and CopyableField, both from the library. - await expect(account.getByRole("textbox", { name: "Name" })).toBeVisible(); + await expect( + account.getByRole("textbox", { name: "Name", exact: true }) + ).toBeVisible(); await expect(account.getByRole("button", { name: "Save" })).toBeVisible(); const card = await contactCard(page).innerText(); expect(JSON.parse(card)).toHaveProperty("Add.payload.share_key"); diff --git a/package.json b/package.json index 5b0ff20..3f1e7f4 100644 --- a/package.json +++ b/package.json @@ -65,14 +65,20 @@ "peerDependencies": { "@automerge/automerge-repo-keyhive": "0.5.0-alpha.5b", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": "^0.1.0", "react": "^18.3.1" }, + "peerDependenciesMeta": { + "@inkandswitch/onomancy": { + "optional": true + } + }, "devDependencies": { "@automerge/automerge-repo-keyhive": "0.5.0-alpha.5b", "@automerge/react": "2.6.0-subduction.48", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.39.5", - "@playwright/test": "1.60.0", + "@playwright/test": "1.61.1", "@types/node": "22.19.4", "@types/react": "^18.3.25", "@typescript-eslint/eslint-plugin": "^8.67.0", diff --git a/playwright.config.ts b/playwright.config.ts index 18f588a..3d2856f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,9 +10,11 @@ export default defineConfig({ expect: { timeout: 20_000 }, // Each test drives its own browser identity, so they cannot share state and - // there is nothing to serialise. + // there is nothing to serialise. Two workers everywhere: every app instance + // handshakes with the real sync server, and higher parallelism starves + // those round-trips into timeouts. fullyParallel: true, - workers: process.env.CI ? 2 : undefined, + workers: 2, // We expect success on the first pass retries: 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51de604..da9dd05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,10 @@ overrides: importers: .: + dependencies: + '@inkandswitch/onomancy': + specifier: ^0.1.0 + version: 0.1.0 devDependencies: '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.5b @@ -25,8 +29,8 @@ importers: specifier: ^9.39.5 version: 9.39.5 '@playwright/test': - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.61.1 + version: 1.61.1 '@types/node': specifier: 22.19.4 version: 22.19.4 @@ -84,6 +88,9 @@ importers: '@automerge/react': 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.1.0 + version: 0.1.0 '@keyhive/keyhive': specifier: 0.1.0-alpha.8 version: 0.1.0-alpha.8 @@ -474,6 +481,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inkandswitch/onomancy@0.1.0': + resolution: {integrity: sha512-hQosbokR9XrGYQkHRNlS/PzPmcVGFpPkRlwlHIQsI/4bvLO2WLiVAEQdkA8Id+aFSc32SKqksUSU3f7RIY5Ykw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -498,6 +508,7 @@ 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==} @@ -519,8 +530,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@playwright/test@1.60.0': - resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} hasBin: true @@ -561,66 +572,79 @@ 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==} @@ -1257,13 +1281,13 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} hasBin: true @@ -1940,6 +1964,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inkandswitch/onomancy@0.1.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1980,9 +2006,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@playwright/test@1.60.0': + '@playwright/test@1.61.1': dependencies: - playwright: 1.60.0 + playwright: 1.61.1 '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2683,11 +2709,11 @@ snapshots: pirates@4.0.7: {} - playwright-core@1.60.0: {} + playwright-core@1.61.1: {} - playwright@1.60.0: + playwright@1.61.1: dependencies: - playwright-core: 1.60.0 + playwright-core: 1.61.1 optionalDependencies: fsevents: 2.3.2 diff --git a/scripts/check-prefix.mjs b/scripts/check-prefix.mjs index edb2490..527b954 100644 --- a/scripts/check-prefix.mjs +++ b/scripts/check-prefix.mjs @@ -21,6 +21,7 @@ const KNOWN_FALSE_POSITIVES = new Set([ "ease-out", "filter", "hidden", + "lowercase", "table", ]); diff --git a/src/components/AccessEditor.tsx b/src/components/AccessEditor.tsx index d9ef429..0971881 100644 --- a/src/components/AccessEditor.tsx +++ b/src/components/AccessEditor.tsx @@ -10,6 +10,7 @@ import { useTargetMembers } from "../access/useTargetMembers.js"; import { ContactBook } from "./ContactBook.js"; import { AccessBadge } from "./primitives/AccessBadge.js"; import { Avatar } from "./primitives/Avatar.js"; +import { DnsNameBadge } from "./primitives/DnsNameBadge.js"; export interface AccessEditorProps { /** The document or group whose membership is being edited. */ @@ -298,6 +299,7 @@ export function AccessEditor({ ) : ( sortedMembers.map((member) => { const label = memberLabel(member, directory, labelForMember); + const entry = directory.lookup(member.id); return (
@@ -317,6 +319,13 @@ export function AccessEditor({ (group) )} + {entry?.dnsName && ( + + )}
diff --git a/src/components/AccountView.tsx b/src/components/AccountView.tsx index 606f585..c08d209 100644 --- a/src/components/AccountView.tsx +++ b/src/components/AccountView.tsx @@ -10,6 +10,12 @@ export interface AccountViewProps { /** Renders a Cancel button when supplied. */ onCancel?: () => void; showIdentifiers?: boolean; + /** + * Offer a field for claiming a DNS name (an onomancy `@` name), verified + * against the domain's DNSSEC-protected `_onomancy` TXT record by a + * verifying directory. + */ + showDnsName?: boolean; /** * Publish the contact card into the directory so someone who finds this * account by name can share with it without needing a new contact card. @@ -31,6 +37,7 @@ export function AccountView({ onSaved, onCancel, showIdentifiers = true, + showDnsName = true, publishContactCard = false, fallbackAvatarSrc, className = "", @@ -42,6 +49,7 @@ export function AccountView({ id={self.id} kind="individual" peerId={self.peerId} + showDnsName={showDnsName} contactCardJson={publishContactCard ? self.contactCardJson : undefined} namePlaceholder="Enter your name" onSaved={onSaved} diff --git a/src/components/ContactBook.tsx b/src/components/ContactBook.tsx index 0071f22..58dd34c 100644 --- a/src/components/ContactBook.tsx +++ b/src/components/ContactBook.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { shortId, useDirectory } from "../directory/context.js"; import type { DirectoryEntry, DirectoryEntryKind } from "../directory/types.js"; import { Avatar } from "./primitives/Avatar.js"; +import { DnsNameBadge } from "./primitives/DnsNameBadge.js"; export interface ContactBookProps { /** Called with the entry the reader picked. */ @@ -21,6 +22,8 @@ function matches(entry: DirectoryEntry, query: string): boolean { const needle = query.toLowerCase(); return ( (entry.name?.toLowerCase().includes(needle) ?? false) || + (entry.dnsName?.toLowerCase().includes(needle.replace(/^@/, "")) ?? + false) || entry.id.toLowerCase().startsWith(needle) ); } @@ -110,6 +113,13 @@ export function ContactBook({ (group) )} + {entry.dnsName && ( + + )} {shortId(entry.id)} diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 7af4d11..4f49208 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useDirectory, useDirectoryEntry } from "../directory/context.js"; import type { DirectoryEntry, DirectoryEntryKind } from "../directory/types.js"; +import { normalizeDnsName } from "../onomancy/runtime.js"; import { Avatar } from "./primitives/Avatar.js"; +import { DnsNameBadge } from "./primitives/DnsNameBadge.js"; export interface ProfileEditorProps { /** Hex-encoded keyhive id whose directory entry this edits. */ @@ -13,6 +15,14 @@ export interface ProfileEditorProps { peerId?: string; nameLabel?: string; namePlaceholder?: string; + /** + * Offer a field for claiming a DNS name (an onomancy `@` name). The claim + * is self-asserted here; a verifying directory checks it against the + * domain's DNSSEC-protected `_onomancy` TXT record. + */ + showDnsName?: boolean; + dnsNameLabel?: string; + dnsNamePlaceholder?: string; saveLabel?: string; /** Rendered between the name field and the buttons. */ children?: ReactNode; @@ -36,6 +46,9 @@ export function ProfileEditor({ peerId, nameLabel = "Name", namePlaceholder = "Enter a name", + showDnsName = false, + dnsNameLabel = "DNS name", + dnsNamePlaceholder = "@example.com", saveLabel = "Save", children, onSaved, @@ -48,15 +61,19 @@ export function ProfileEditor({ const fieldId = useId(); const [name, setName] = useState(entry?.name ?? ""); + const [dnsName, setDnsName] = useState(entry?.dnsName ?? ""); const [avatarFile, setAvatarFile] = useState(null); const [filePreview, setFilePreview] = useState(null); const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); const nameEdited = useRef(false); + const dnsNameEdited = useRef(false); useEffect(() => { nameEdited.current = false; + dnsNameEdited.current = false; setName(entry?.name ?? ""); + setDnsName(entry?.dnsName ?? ""); // Only when the subject changes, so typing is not interrupted. // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); @@ -65,6 +82,10 @@ export function ProfileEditor({ if (!nameEdited.current && entry?.name) setName(entry.name); }, [entry?.name]); + useEffect(() => { + if (!dnsNameEdited.current && entry?.dnsName) setDnsName(entry.dnsName); + }, [entry?.dnsName]); + useEffect(() => { if (!avatarFile) { setFilePreview(null); @@ -85,6 +106,14 @@ export function ProfileEditor({ const avatar = avatarFile ? new Uint8Array(await avatarFile.arrayBuffer()) : (entry?.avatar ?? null); + // The empty string clears an existing claim; a hidden field leaves it be. + const claimed = showDnsName + ? dnsName.trim() + ? normalizeDnsName(dnsName) + : entry?.dnsName + ? "" + : undefined + : undefined; const updated: DirectoryEntry = { id, name, @@ -94,6 +123,7 @@ export function ProfileEditor({ ...(contactCardJson !== undefined ? { contactCard: contactCardJson } : {}), + ...(claimed !== undefined ? { dnsName: claimed } : {}), }; await directory.publish(updated); setAvatarFile(null); @@ -177,6 +207,39 @@ export function ProfileEditor({ />
+ {showDnsName && ( +
+ + { + dnsNameEdited.current = true; + setDnsName(e.target.value); + }} + className="kh-w-full kh-px-3 kh-py-2 kh-border kh-border-border kh-rounded-md kh-shadow-sm focus:kh-outline-none focus:kh-ring-2 focus:kh-ring-ring focus:kh-border-ring kh-bg-background kh-text-foreground kh-font-mono" + placeholder={dnsNamePlaceholder} + /> +

+ A domain that names this identity through an _onomancy{" "} + DNS record. The claim is only trustworthy once verified. +

+
+ )} + {children} {!directory.writable && ( diff --git a/src/components/primitives/DnsNameBadge.tsx b/src/components/primitives/DnsNameBadge.tsx new file mode 100644 index 0000000..93861e5 --- /dev/null +++ b/src/components/primitives/DnsNameBadge.tsx @@ -0,0 +1,64 @@ +import type { DnsNameStatus } from "../../directory/types.js"; + +export interface DnsNameBadgeProps { + /** The claimed hostname, without the `@` sigil. */ + dnsName: string; + /** Absent when the directory in scope does not verify claims. */ + status?: DnsNameStatus; + className?: string; +} + +const STATUS_GLYPH: Record = { + verified: "\u2713", + pending: "\u2026", + mismatch: "\u2717", + unreachable: "?", + unsynced: "?", + invalid: "\u2717", +}; + +const STATUS_TITLE: Record = { + verified: "DNSSEC-verified: this domain designates this identity.", + pending: "Checking this domain's DNS binding.", + mismatch: "This domain's DNS binding designates a different identity.", + unreachable: "This domain's DNS binding could not be resolved.", + unsynced: + "This domain designates a document this device has not synced, so the claim cannot be checked yet.", + invalid: "Not a valid DNS name.", +}; + +const STATUS_TONE: Record = { + verified: "kh-text-primary kh-border-primary", + pending: "kh-text-muted-foreground kh-border-border", + mismatch: "kh-text-destructive kh-border-destructive", + unreachable: "kh-text-muted-foreground kh-border-border", + unsynced: "kh-text-muted-foreground kh-border-border", + invalid: "kh-text-destructive kh-border-destructive", +}; + +/** + * A claimed DNS name, such as `@expede.wtf`, with its verification state. + * + * Without a status the claim renders as exactly that: a claim, visually no + * stronger than a self-asserted display name. + */ +export function DnsNameBadge({ + dnsName, + status, + className = "", +}: DnsNameBadgeProps) { + const tone = status + ? STATUS_TONE[status] + : "kh-text-muted-foreground kh-border-border"; + + return ( + + @{dnsName} + {status && } + {status && ({status})} + + ); +} diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index a2a6515..c5370a0 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -5,6 +5,13 @@ import type { NameDirectory, } from "./types.js"; +/** + * The reserved top-level key onomancy namestore data lives under when the + * directory document doubles as a root namestore. Never a directory entry: + * profile entries and namestore edges share the document without colliding. + */ +export const RESERVED_ONOMANCY_KEY = "onomancy"; + /** Hex-encoded keyhive id to display information. */ export type DirectoryDoc = Record< string, @@ -14,6 +21,7 @@ export type DirectoryDoc = Record< avatar?: Uint8Array | null; kind?: DirectoryEntryKind; contactCard?: string; + dnsName?: string; } >; @@ -47,13 +55,16 @@ export function createAutomergeDocDirectory( notice: options.notice ?? DEFAULT_NOTICE, lookup(id) { + if (id === RESERVED_ONOMANCY_KEY) return undefined; const record = doc?.[id]; return record ? { id, ...record } : undefined; }, list() { if (!doc) return []; - return Object.entries(doc).map(([id, record]) => ({ id, ...record })); + return Object.entries(doc) + .filter(([id]) => id !== RESERVED_ONOMANCY_KEY) + .map(([id, record]) => ({ id, ...record })); }, }; @@ -70,6 +81,7 @@ export function createAutomergeDocDirectory( if (entry.kind !== undefined) record.kind = entry.kind; if (entry.contactCard !== undefined) record.contactCard = entry.contactCard; + if (entry.dnsName) record.dnsName = entry.dnsName; d[entry.id] = record; return; } @@ -80,6 +92,11 @@ export function createAutomergeDocDirectory( if (entry.kind !== undefined) existing.kind = entry.kind; if (entry.contactCard !== undefined) existing.contactCard = entry.contactCard; + // The empty string clears a claim; undefined leaves it alone. + if (entry.dnsName !== undefined) { + if (entry.dnsName === "") delete existing.dnsName; + else existing.dnsName = entry.dnsName; + } }); }; } diff --git a/src/directory/types.ts b/src/directory/types.ts index 3aed3d7..2b64b2f 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -4,6 +4,21 @@ export type DirectoryTrust = "unverified" | "verified"; /** What an entry's id refers to. Groups are named here like individuals are. */ export type DirectoryEntryKind = "individual" | "group"; +/** + * Where a DNS name claim stands with a verifying directory. + * + * - `pending`: the claim is being resolved. + * - `verified`: a DNSSEC-validated `_onomancy` TXT record designates this id. + * - `mismatch`: the record designates a different id. + * - `unreachable`: the binding could not be resolved (offline, no record, or + * an invalid chain), which proves nothing either way. + * - `unsynced`: the domain designates a document this device has not synced, + * so membership cannot be checked yet. Also proves nothing either way. + * - `invalid`: the claim is not a DNS name at all. + */ +export type DnsNameStatus = + "pending" | "verified" | "mismatch" | "unreachable" | "unsynced" | "invalid"; + /** Display information for one keyhive identity. */ export interface DirectoryEntry { /** Hex-encoded keyhive identifier, as `listMembers` returns it. */ @@ -18,6 +33,18 @@ export interface DirectoryEntry { * needing to paste one in. Individuals only since a group has no card. */ contactCard?: string; + /** + * A claimed DNS name, such as `expede.wtf`, stored without the `@` sigil. + * Self-asserted until a verifying directory checks its `_onomancy` TXT + * record. Empty string on publish clears the claim. + */ + dnsName?: string; + /** + * Set by a verifying directory (see `createOnomancyDirectory`), never + * stored. Absent when the entry claims no DNS name or the directory does + * not verify. + */ + dnsNameStatus?: DnsNameStatus; } export interface NameDirectory { diff --git a/src/index.ts b/src/index.ts index a7aafdd..604d67d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export type { DirectoryEntry, DirectoryEntryKind, DirectoryTrust, + DnsNameStatus, NameDirectory, } from "./directory/types.js"; export { emptyDirectory } from "./directory/types.js"; @@ -17,7 +18,10 @@ export { useDisplayName, } from "./directory/context.js"; export type { DirectoryContextValue } from "./directory/context.js"; -export { createAutomergeDocDirectory } from "./directory/automerge-directory.js"; +export { + createAutomergeDocDirectory, + RESERVED_ONOMANCY_KEY, +} from "./directory/automerge-directory.js"; export type { AutomergeDocDirectoryOptions, DirectoryDoc, @@ -25,6 +29,30 @@ export type { } from "./directory/automerge-directory.js"; export { useAutomergeDocDirectory } from "./directory/useAutomergeDocDirectory.js"; +export { + createOnomancyRuntime, + normalizeDnsName, + parseRecordDocId, +} from "./onomancy/runtime.js"; +export type { + HostnameBinding, + OnomancyModule, + OnomancyRuntime, + OnomancyRuntimeOptions, +} from "./onomancy/runtime.js"; +export { + createKeyhiveDesignation, + idEqualityDesignation, +} from "./onomancy/designation.js"; +export type { + DesignationVerdict, + DnsDesignation, + KeyhiveDesignationOptions, +} from "./onomancy/designation.js"; +export { createOnomancyDirectory } from "./onomancy/verified-directory.js"; +export type { OnomancyDirectoryOptions } from "./onomancy/verified-directory.js"; +export { useOnomancyDirectory } from "./onomancy/useOnomancyDirectory.js"; + export { agentKindOf, createDocumentTarget, @@ -60,6 +88,8 @@ export type { AccessEditorProps } from "./components/AccessEditor.js"; export { AccessBadge } from "./components/primitives/AccessBadge.js"; export type { AccessBadgeProps } from "./components/primitives/AccessBadge.js"; +export { DnsNameBadge } from "./components/primitives/DnsNameBadge.js"; +export type { DnsNameBadgeProps } from "./components/primitives/DnsNameBadge.js"; export { Avatar } from "./components/primitives/Avatar.js"; export type { AvatarProps } from "./components/primitives/Avatar.js"; export { CopyableField } from "./components/primitives/CopyableField.js"; diff --git a/src/onomancy/designation.ts b/src/onomancy/designation.ts new file mode 100644 index 0000000..e7b88a3 --- /dev/null +++ b/src/onomancy/designation.ts @@ -0,0 +1,94 @@ +import type { AutomergeRepoKeyhiveBase } from "@automerge/automerge-repo-keyhive"; +import { bytesToHex, hexToBytes } from "../bytes.js"; +import type { DirectoryEntry } from "../directory/types.js"; +import type { KeyhiveRuntime } from "../runtime.js"; + +/** + * Whether a DNS binding's root documents designate an identity. + * + * `unknown` is for verdicts the local device cannot reach: the designated + * document exists but is not held here, so membership can be checked only + * after a sync. It is not evidence in either direction. + */ +export type DesignationVerdict = "designates" | "excludes" | "unknown"; + +/** + * The authority half of DNS name verification. The DNS layer proves + * `hostname → root document ids`; a designation decides whether those + * documents belong to the entry's identity. + */ +export type DnsDesignation = ( + entry: DirectoryEntry, + boundIds: string[], + hostname: string +) => Promise | DesignationVerdict; + +/** + * The solo case: the bound id is the identity itself. This is the default, + * and the right check when accounts anchor domains directly to their key. + */ +export const idEqualityDesignation: DnsDesignation = (entry, boundIds) => + boundIds.includes(bareId(entry.id)) ? "designates" : "excludes"; + +export interface KeyhiveDesignationOptions { + /** + * The least access that counts as the domain designating someone, as + * `Access.fromString` accepts it. Admin by default: controlling the root + * namestore document is what owning the name means. + */ + minimumAccess?: string; +} + +/** + * Designation through keyhive: the domain binds a root namestore document, + * and the identities the document delegates admin access to are the ones it + * designates. Ownership is shared by inviting more admins; the DNS record + * never changes. + * + * The solo case is included: a bound id that is the identity itself + * designates directly, so anchors of either shape verify. + * + * Only the document's own delegations are consulted, so an identity holding + * admin through a nested group is `unknown` here, not excluded. A document + * this device has not synced is `unknown` too. + */ +export function createKeyhiveDesignation( + runtime: KeyhiveRuntime, + hive: AutomergeRepoKeyhiveBase, + options: KeyhiveDesignationOptions = {} +): DnsDesignation { + const level = options.minimumAccess ?? "admin"; + + return async (entry, boundIds) => { + const entryId = bareId(entry.id); + if (boundIds.includes(entryId)) return "designates"; + + const minimum = runtime.Access.fromString(level); + let anyHeld = false; + let anyDirectMember = false; + + for (const boundId of boundIds) { + const document = await hive.keyhive.getDocument( + new runtime.DocumentId(hexToBytes(boundId)) + ); + if (!document) continue; + anyHeld = true; + + for (const capability of await document.members()) { + const memberId = bytesToHex(capability.who.id.toBytes()); + if (memberId !== entryId) continue; + anyDirectMember = true; + if (capability.can.atLeast(minimum)) return "designates"; + } + } + + // Only a direct delegation below the minimum excludes. An unheld + // document proves nothing, and neither does absence from the direct + // members: access may route through a group this check does not walk. + return anyHeld && anyDirectMember ? "excludes" : "unknown"; + }; +} + +function bareId(id: string): string { + return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); +} diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts new file mode 100644 index 0000000..ad873dd --- /dev/null +++ b/src/onomancy/runtime.ts @@ -0,0 +1,126 @@ +import { bytesToHex } from "../bytes.js"; + +/** + * 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. + */ +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[] }`. + */ + resolveHostname(hostname: string, dohUrl?: string | null): Promise; +} + +export interface OnomancyRuntimeOptions { + /** DNS-over-HTTPS endpoint. The Wasm module's default when omitted. */ + dohUrl?: string; +} + +/** + * A DNSSEC-verified binding: the root document ids a hostname's + * `_onomancy` TXT records designate. + */ +export interface HostnameBinding { + hostname: string; + /** + * Hex-encoded 32-byte root document ids (ed25519 verifying keys) from the + * `p=` field of each parseable `v=ONO0` record. Usually one; more during + * a migration's dual-publish window. + */ + ids: string[]; +} + +export interface OnomancyRuntime { + /** + * The DNSSEC-verified root document ids bound to `hostname`. + * + * Rejects on malformed hostnames, transport failures, and invalid chains. + */ + resolveBoundIds(hostname: string): Promise; +} + +/** Build a runtime from the application's own onomancy import. */ +export function createOnomancyRuntime( + onomancy: OnomancyModule, + options: OnomancyRuntimeOptions = {} +): OnomancyRuntime { + return { + async resolveBoundIds(hostname) { + const outcome = await onomancy.resolveHostname( + hostname, + options.dohUrl ?? null + ); + return { hostname, ids: boundIdsOf(outcome) }; + }, + }; +} + +/** The hex-encoded `p=` document ids in a `resolveHostname` outcome. */ +function boundIdsOf(outcome: unknown): string[] { + if (typeof outcome !== "object" || outcome === null) return []; + const records = (outcome as { records?: unknown }).records; + if (!Array.isArray(records)) return []; + const ids: string[] = []; + for (const record of records) { + if (typeof record !== "string") continue; + const id = parseRecordDocId(record); + if (id !== undefined) ids.push(id); + } + return ids; +} + +/** + * The hex-encoded root document id of one TXT record, or `undefined` when the + * record 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. + */ +export function parseRecordDocId(record: string): string | undefined { + const match = record.match( + /^v=ONO0;k=ed25519;n=\d+;g=[A-Za-z0-9+/]+={0,2};p=([A-Za-z0-9+/]+={0,2})$/ + ); + if (!match) return undefined; + const bytes = base64ToBytes(match[1]); + if (bytes === undefined || bytes.length !== 32) return undefined; + return bytesToHex(bytes); +} + +function base64ToBytes(base64: string): Uint8Array | undefined { + try { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + } catch { + return undefined; + } +} + +/** + * Parse a claimed DNS name into its canonical form: lowercase, leading `@` + * and trailing dot stripped. Throws on names the DNS anchoring grammar + * rejects, such as dotless names and IP literals. + */ +export function normalizeDnsName(raw: string): string { + let name = raw.trim().toLowerCase(); + if (name.startsWith("@")) name = name.slice(1); + if (name.endsWith(".")) name = name.slice(0, -1); + + if (name.length === 0 || name.length > 253) { + throw new Error(`Not a DNS name: "${raw}"`); + } + const labels = name.split("."); + // A dotless name is a flat parse error, never a hostname. + if (labels.length < 2) { + throw new Error(`A DNS name needs at least one dot: "${raw}"`); + } + for (const label of labels) { + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label) || label.length > 63) { + throw new Error(`Not a DNS label: "${label}" in "${raw}"`); + } + } + // IP literals are rejected under `@`. + if (labels.every((label) => /^\d+$/.test(label))) { + throw new Error(`IP literals cannot be onomancy names: "${raw}"`); + } + return name; +} diff --git a/src/onomancy/useOnomancyDirectory.ts b/src/onomancy/useOnomancyDirectory.ts new file mode 100644 index 0000000..289be98 --- /dev/null +++ b/src/onomancy/useOnomancyDirectory.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; +import type { NameDirectory } from "../directory/types.js"; +import type { OnomancyRuntime } from "./runtime.js"; +import { + createOnomancyDirectory, + type OnomancyDirectoryOptions, +} from "./verified-directory.js"; + +/** + * `createOnomancyDirectory` memoized on the base directory, so verification + * results are re-checked when the base directory's identity changes. + */ +export function useOnomancyDirectory( + base: NameDirectory, + runtime: OnomancyRuntime, + options: OnomancyDirectoryOptions = {} +): NameDirectory { + const { designation, notice } = options; + return useMemo( + () => createOnomancyDirectory(base, runtime, { designation, notice }), + [base, runtime, designation, notice] + ); +} diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts new file mode 100644 index 0000000..3fa3181 --- /dev/null +++ b/src/onomancy/verified-directory.ts @@ -0,0 +1,184 @@ +import type { + DirectoryEntry, + DnsNameStatus, + NameDirectory, +} from "../directory/types.js"; +import { + idEqualityDesignation, + type DesignationVerdict, + type DnsDesignation, +} from "./designation.js"; +import { normalizeDnsName, type OnomancyRuntime } from "./runtime.js"; + +type Resolution = + | { phase: "pending" } + | { phase: "resolved"; ids: string[] } + | { phase: "unreachable" }; + +type Verdict = + { phase: "pending" } | { phase: "done"; verdict: DesignationVerdict }; + +export interface OnomancyDirectoryOptions { + /** + * Decides whether the bound root documents designate an entry's identity. + * Defaults to {@link idEqualityDesignation} (the bound id is the identity). + * Pass `createKeyhiveDesignation` for domains that bind a shared root + * namestore document whose admins own the name. + */ + designation?: DnsDesignation; + /** Overrides the base directory's notice. */ + notice?: string; +} + +/** + * Wrap a directory so entries that claim a DNS name (`entry.dnsName`) carry a + * verification status (`entry.dnsNameStatus`). + * + * Verification is two layers, checked lazily the first time an entry is read + * and cached for the directory's lifetime (build a fresh one to re-check): + * + * 1. DNS: the hostname's `_onomancy` TXT record is fetched over DoH and + * validated by DNSSEC from the IANA root, yielding root document ids. + * 2. Designation: does a bound document belong to this identity? By default + * the bound id must be the identity itself; a keyhive designation accepts + * admins of a shared root document instead. + * + * Subscribers are notified when a check lands, so a `DirectoryProvider` + * re-renders with the result. + */ +export function createOnomancyDirectory( + base: NameDirectory, + runtime: OnomancyRuntime, + options: OnomancyDirectoryOptions = {} +): NameDirectory { + const designation = options.designation ?? idEqualityDesignation; + const resolutions = new Map(); + const verdicts = new Map(); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + + function resolutionFor(hostname: string): Resolution { + const existing = resolutions.get(hostname); + if (existing) return existing; + + const pending: Resolution = { phase: "pending" }; + resolutions.set(hostname, pending); + runtime.resolveBoundIds(hostname).then( + (binding) => { + // No parseable records proves nothing about any identity, the same + // as not resolving at all. A mismatch requires a record that + // designates someone. + resolutions.set( + hostname, + binding.ids.length === 0 + ? { phase: "unreachable" } + : { phase: "resolved", ids: binding.ids.map(bareId) } + ); + notify(); + }, + () => { + resolutions.set(hostname, { phase: "unreachable" }); + notify(); + } + ); + return pending; + } + + function verdictFor( + entry: DirectoryEntry, + hostname: string, + ids: string[] + ): Verdict { + const key = `${hostname} ${bareId(entry.id)}`; + const existing = verdicts.get(key); + if (existing) return existing; + + const pending: Verdict = { phase: "pending" }; + verdicts.set(key, pending); + Promise.resolve(designation(entry, ids, hostname)).then( + (verdict) => { + verdicts.set(key, { phase: "done", verdict }); + notify(); + }, + () => { + // A designation that throws has answered nothing. + verdicts.set(key, { phase: "done", verdict: "unknown" }); + notify(); + } + ); + return pending; + } + + function decorate(entry: DirectoryEntry): DirectoryEntry { + if (!entry.dnsName) return entry; + + let hostname: string; + try { + hostname = normalizeDnsName(entry.dnsName); + } catch { + return { ...entry, dnsNameStatus: "invalid" }; + } + + const resolution = resolutionFor(hostname); + if (resolution.phase === "pending") { + return { ...entry, dnsNameStatus: "pending" }; + } + if (resolution.phase === "unreachable") { + return { ...entry, dnsNameStatus: "unreachable" }; + } + + const verdict = verdictFor(entry, hostname, resolution.ids); + const status: DnsNameStatus = + verdict.phase === "pending" + ? "pending" + : verdict.verdict === "designates" + ? "verified" + : verdict.verdict === "excludes" + ? "mismatch" + : "unsynced"; + return { ...entry, dnsNameStatus: status }; + } + + const directory: NameDirectory = { + source: `onomancy(${base.source})`, + trust: base.trust, + writable: base.writable, + enumerable: base.enumerable, + notice: options.notice ?? base.notice, + + lookup(id) { + const entry = base.lookup(id); + return entry && decorate(entry); + }, + + list() { + return base.list().map(decorate); + }, + + subscribe(listener) { + listeners.add(listener); + const unsubscribe = base.subscribe?.(listener); + return () => { + listeners.delete(listener); + unsubscribe?.(); + }; + }, + }; + + const publish = base.publish?.bind(base); + if (publish) { + directory.publish = (entry) => { + // The status is a decoration, never stored. + const { dnsNameStatus: _status, ...stored } = entry; + return publish(stored); + }; + } + + return directory; +} + +function bareId(id: string): string { + return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); +} diff --git a/src/runtime.ts b/src/runtime.ts index d8625a0..09b2470 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -22,6 +22,9 @@ export interface KeyhiveRuntime { readonly ContactCard: { fromJson(json: string): ContactCard | undefined; }; + readonly DocumentId: { + new (bytes: Uint8Array): DocumentId; + }; readonly Identifier: { new (bytes: Uint8Array): Identifier; publicId(): Identifier; @@ -38,6 +41,7 @@ export function createKeyhiveRuntime(ark: KeyhiveModule): KeyhiveRuntime { return { Access: ark.Access, ContactCard: ark.ContactCard, + DocumentId: ark.DocumentId, Identifier: ark.Identifier, docIdFromAutomergeUrl: (url) => ark.docIdFromAutomergeUrl(url), isUnprotectedDoc: (url) => ark.isUnprotectedDoc(url), From f0bd51453251bdc0c77cd9db039282bc4ff93ccb Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:29 -0700 Subject: [PATCH 03/16] Split the onomancy mechanism into its own entry point Everything that performs DNS moves behind ./onomancy; the main entry keeps the status vocabulary and the rendering. An application that computes dnsNameStatus itself can use the components without importing any resolution machinery. Also: documentDelegatesTo asks whether a document delegates to an identity (no DNS, no presentation); the VerificationCache gets listener plumbing so checks finishing after a directory rebuild still notify; peer dep moves to @automerge/automerge-repo-keyhive >=0.5.0-alpha.6. --- README.md | 75 ++++++++++++- apps/component-test-app/package.json | 2 +- apps/component-test-app/src/App.tsx | 26 +++-- apps/component-test-app/src/nameResolution.ts | 7 +- apps/component-test-app/src/onomancyStub.ts | 6 +- e2e/dns-names.spec.ts | 7 +- e2e/shared-directory.spec.ts | 11 +- package.json | 16 ++- pnpm-lock.yaml | 14 +-- src/access/delegation.ts | 89 +++++++++++++++ src/components/AccountView.tsx | 8 ++ src/components/ProfileEditor.tsx | 28 ++++- src/directory/automerge-directory.ts | 10 ++ src/directory/types.ts | 37 ++++++- src/index.ts | 31 ++---- src/onomancy/designation.ts | 84 +++++++-------- src/onomancy/index.ts | 51 +++++++++ src/onomancy/runtime.ts | 101 ++++++++++++------ src/onomancy/useOnomancyDirectory.ts | 30 ++++-- src/onomancy/verified-directory.ts | 87 +++++++++++++-- src/runtime.ts | 16 ++- 21 files changed, 579 insertions(+), 157 deletions(-) create mode 100644 src/access/delegation.ts create mode 100644 src/onomancy/index.ts diff --git a/README.md b/README.md index cb3f2de..597132c 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ pnpm add @automerge/keyhive-react `@automerge/automerge-repo-keyhive`, `@automerge/react` and `react` are peer dependencies. The package imports none of them at runtime (see [The keyhive runtime](#the-keyhive-runtime)), so the application's copy is the -only one loaded. `@inkandswitch/onomancy` is an optional peer dependency, -supplied the same way, for [DNS names](#dns-names). +only one loaded. [DNS names](#dns-names) work the same way, through the +separate `@automerge/keyhive-react/onomancy` entry point. ## What is in it @@ -105,6 +105,28 @@ DNSSEC-protected `_onomancy` TXT record whose `p=` field is the identity's ed25519 verifying key, and the record is validated locally from the IANA root — no registry, no certificate authority, and no trust in whoever relayed it. +### Where the pieces live + +This package keeps the _vocabulary_ and sheds the _mechanism_. The main entry +point knows what a claim is, what the six statuses mean, and how to render +them; it resolves nothing. Everything that performs DNS lives behind a +separate import: + +```ts +import { DnsNameBadge, type DnsNameStatus } from "@automerge/keyhive-react"; +import { useOnomancyDirectory } from "@automerge/keyhive-react/onomancy"; +``` + +So the subpath is optional in practice. An application that computes +`dnsNameStatus` itself — because it already holds onomancy, or verifies +against something that is not DNS — uses the components and the +`DirectoryEntry` fields without importing any of it. The rules such a status +must obey are documented on `DnsNameStatus`, which stays on the main entry so +it binds either way. + +The isolation guarantee covers both entry points: neither imports anything +but React, and the build fails if that stops being true. + Like keyhive, onomancy is Wasm-backed, so the application supplies its own copy through a runtime and this package imports nothing: @@ -113,7 +135,7 @@ import * as onomancy from "@inkandswitch/onomancy"; import { createOnomancyRuntime, useOnomancyDirectory, -} from "@automerge/keyhive-react"; +} from "@automerge/keyhive-react/onomancy"; const onomancyRuntime = createOnomancyRuntime(onomancy); @@ -152,13 +174,58 @@ not synced reads `unsynced` — not evidence either way — until a replica arrives. `AccountView` offers the field for claiming a name (turn it off with -`showDnsName={false}`). Publishing an empty string withdraws the claim. +`showDnsName={false}`). Publishing an empty string withdraws the claim. Pass +`normalizeDnsName={onomancyRuntime.normalizeDnsName}` to reject a malformed +claim as it is typed, against onomancy's own grammar; without it the field +canonicalises spelling but cannot tell a hostname from a typo, and the bad +claim is stored and later rendered `invalid`. + +### Why a forgeable claim is safe to store + +The directory holding these claims is ordinary data. Anyone who can write to +it can write anything into it, including somebody else's domain. That is fine: + +> A claim is forgeable. A badge is not. Anyone can write +> `dnsName: "example.com"` into anyone's entry, but the badge is not read from +> the document — it comes from resolving the domain and checking what that +> domain designates. A forged claim renders `mismatch` or `unreachable`. +> Nobody can write their way to `verified`. +> +> The document carries the assertion. DNS carries the authority. + +This is why the directory abstraction can stay data-only and swappable, why a +directory document that anyone holding its id may write is an acceptable place +to keep claims, and why `publish` strips `dnsNameStatus` before writing. + +### The errors run one way A verified badge proves that the domain, as attested by a DNSSEC chain from the IANA root during the chain's signature window, designated this identity. It proves nothing about the domain owner's intentions, and nothing about any other name. +The design has **no false positives and real false negatives**, deliberately: + +- It will not wrongly verify. Every path to `verified` requires positive + evidence from outside the document. +- It will sometimes fail to verify someone legitimate. A record that fails to + parse reads `unreachable`; a designated document this device has not synced + reads `unsynced`; and an identity holding admin _through a group_ reads + `unsynced` too, because keyhive's `members()` reports a document's own + delegations and those do not change when a group that already has access + gains a member. + +That last one is a real gap and worth stating precisely. Fixing the _wording_ +is possible today; fixing the _verdict_ is not. The only evidence available +about indirect access is `cgkaMembers()`, which returns bare `Identifier`s — +and `Identifier` carries no access level at all, so it can never satisfy an +admin minimum. Verifying a nested-group admin needs transitive delegations +_with_ their capabilities, which no current API exposes. + +Never wrongly verifying while sometimes failing to verify is the right trade +for a naming system, and the gap above is an instance of that choice rather +than an exception to it. + ## Styling ```ts diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index 2102ab2..6875b10 100644 --- a/apps/component-test-app/package.json +++ b/apps/component-test-app/package.json @@ -11,7 +11,7 @@ "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", "@automerge/automerge-repo": "2.6.0-subduction.48", - "@automerge/automerge-repo-keyhive": "0.5.0-alpha.5b", + "@automerge/automerge-repo-keyhive": "0.5.0-alpha.6", "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.48", "@automerge/automerge-subduction": "0.16.1", "@automerge/keyhive-react": "workspace:*", diff --git a/apps/component-test-app/src/App.tsx b/apps/component-test-app/src/App.tsx index 5e09908..cea1ce3 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -21,19 +21,21 @@ import { CopyableField, createDocumentTarget, createGroupTarget, - createKeyhiveDesignation, - createOnomancyRuntime, DirectoryProvider, - idEqualityDesignation, type DirectoryDoc, - type DnsDesignation, type NameDirectory, AccessEditor, ProfileEditor, useAutomergeDocDirectory, useKeyhiveUpdates, - useOnomancyDirectory, } from "@automerge/keyhive-react"; +import { + createKeyhiveDesignation, + createOnomancyRuntime, + idEqualityDesignation, + useOnomancyDirectory, + type DnsDesignation, +} from "@automerge/keyhive-react/onomancy"; import { composeDirectories } from "./composeDirectories"; import { DocumentPanel, LoadDocument } from "./DocumentPanel"; import { @@ -253,6 +255,7 @@ export default function App({ hive, repo }: AppProps) { onBindName={bindName} onResolveName={resolveName} bindReady={directoryDoc !== undefined} + normalizeDnsName={onomancyRuntime.normalizeDnsName} /> ); @@ -264,6 +267,12 @@ interface TestAppProps extends AppProps { onBindName: (path: string, url: AutomergeUrl) => Promise; onResolveName: (raw: string) => Promise; bindReady: boolean; + /** + * The onomancy grammar, so a typed claim is rejected at entry rather than + * stored and later rendered `invalid`. The library's components know what + * a claim is; only the app holds the parser that decides one. + */ + normalizeDnsName: (raw: string) => string; } function TestApp({ @@ -274,6 +283,7 @@ function TestApp({ onBindName, onResolveName, bindReady, + normalizeDnsName, }: TestAppProps) { const keyhiveVersion = useKeyhiveUpdates(hive); const [docUrl, setDocUrl] = useState(null); @@ -368,7 +378,11 @@ function TestApp({ Names are written to the shared directory document below, with a local copy kept in this browser.

- +
diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts index 282746f..f8a6117 100644 --- a/apps/component-test-app/src/nameResolution.ts +++ b/apps/component-test-app/src/nameResolution.ts @@ -4,11 +4,8 @@ import { type AutomergeUrl, type Repo, } from "@automerge/react/slim"; -import { - hexToBytes, - RESERVED_ONOMANCY_KEY, - type OnomancyRuntime, -} from "@automerge/keyhive-react"; +import { hexToBytes, RESERVED_ONOMANCY_KEY } from "@automerge/keyhive-react"; +import type { OnomancyRuntime } from "@automerge/keyhive-react/onomancy"; /** * The path-resolution walk over locally held documents, per the onomancy diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts index 12ffaa2..766cd99 100644 --- a/apps/component-test-app/src/onomancyStub.ts +++ b/apps/component-test-app/src/onomancyStub.ts @@ -1,5 +1,5 @@ import * as onomancy from "@inkandswitch/onomancy"; -import type { OnomancyModule } from "@automerge/keyhive-react"; +import type { OnomancyModule } from "@automerge/keyhive-react/onomancy"; /** * Real onomancy for real domains; deterministic outcomes under `.test`, so @@ -14,6 +14,10 @@ import type { OnomancyModule } from "@automerge/keyhive-react"; */ 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. + Name: onomancy.Name, + resolveHostname(hostname: string, dohUrl?: string | null) { if (!hostname.endsWith(".test")) { return onomancy.resolveHostname(hostname, dohUrl); diff --git a/e2e/dns-names.spec.ts b/e2e/dns-names.spec.ts index 195058f..9787c3d 100644 --- a/e2e/dns-names.spec.ts +++ b/e2e/dns-names.spec.ts @@ -56,9 +56,14 @@ test.describe("DNS names verified through onomancy", () => { await openApp(page); await claimDnsName(page, "nodots"); + // The wording is onomancy's, not ours: claims are parsed by the grammar + // from the spec rather than by a hand-rolled check that could drift + // from it. Asserted loosely for that reason — what matters is that the + // claim is refused and nothing is stored. await expect(section(page, "Account").getByRole("alert")).toContainText( - "at least one dot" + /dotless/i ); + await expect(badge(page, "@nodots")).not.toBeVisible(); }); test("clearing the field withdraws the claim", async ({ page }) => { diff --git a/e2e/shared-directory.spec.ts b/e2e/shared-directory.spec.ts index 9d74ee0..a992a5d 100644 --- a/e2e/shared-directory.spec.ts +++ b/e2e/shared-directory.spec.ts @@ -30,12 +30,11 @@ function contactResult(page: Page, name: string) { } test.describe("names shared through a directory document", () => { - // Keyhive delegations sync between profiles (the grant shows up on both - // sides), but Automerge document CONTENTS do not currently arrive at a - // second profile — the pre-existing Document section has the same gap - // ("Loading the document…" forever, access Read). Un-skip when - // cross-profile document sync works in the underlying stack. - test.fixme("two identities see each other's names after sharing one directory", async ({ + // Previously skipped: on @automerge/automerge-repo-keyhive 0.5.0-alpha.5b, + // keyhive delegations synced between profiles (the grant showed up on both + // sides) but Automerge document CONTENTS never arrived at the second + // profile — with Edit here, and with Read in the Document section. + test("two identities see each other's names after sharing one directory", async ({ page, browser, }) => { diff --git a/package.json b/package.json index 3f1e7f4..9be0e6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automerge/keyhive-react", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.5", "description": "React components and hooks for keyhive access control.", "license": "MIT", "repository": { @@ -24,6 +24,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./onomancy": { + "types": "./dist/onomancy/index.d.ts", + "import": "./dist/onomancy/index.js" + }, "./styles.css": "./dist/keyhive-react.css", "./package.json": "./package.json" }, @@ -63,18 +67,12 @@ "tsc:e2e": "tsc -p e2e/tsconfig.json" }, "peerDependencies": { - "@automerge/automerge-repo-keyhive": "0.5.0-alpha.5b", + "@automerge/automerge-repo-keyhive": ">=0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", - "@inkandswitch/onomancy": "^0.1.0", "react": "^18.3.1" }, - "peerDependenciesMeta": { - "@inkandswitch/onomancy": { - "optional": true - } - }, "devDependencies": { - "@automerge/automerge-repo-keyhive": "0.5.0-alpha.5b", + "@automerge/automerge-repo-keyhive": "0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.39.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da9dd05..ff8bd8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: version: 0.1.0 devDependencies: '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.5b - version: 0.5.0-alpha.5b(ws@8.21.3) + specifier: 0.5.0-alpha.6 + version: 0.5.0-alpha.6(ws@8.21.3) '@automerge/react': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@19.2.8(react@18.3.1))(react@18.3.1) @@ -74,8 +74,8 @@ importers: specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.5b - version: 0.5.0-alpha.5b(ws@8.21.3) + specifier: 0.5.0-alpha.6 + version: 0.5.0-alpha.6(ws@8.21.3) '@automerge/automerge-repo-storage-indexeddb': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 @@ -126,8 +126,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@automerge/automerge-repo-keyhive@0.5.0-alpha.5b': - resolution: {integrity: sha512-oBpkFltC+mRSVtH5jTS8OHI+CIte7xpEDoRPdUOgRSBZGWnTGvN97EhysRW1ZtNT1xeau/nWGuIzAmod44tEFg==} + '@automerge/automerge-repo-keyhive@0.5.0-alpha.6': + resolution: {integrity: sha512-74Q/YgdhMHq8AMt+ediPuod7hm9kqp2wS9femw6srw5P3ImTbOiXU7sq7Fns+113qadbmIf8U8RhHFBFduUldw==} engines: {node: '>=22.13'} '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': @@ -1572,7 +1572,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@automerge/automerge-repo-keyhive@0.5.0-alpha.5b(ws@8.21.3)': + '@automerge/automerge-repo-keyhive@0.5.0-alpha.6(ws@8.21.3)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 diff --git a/src/access/delegation.ts b/src/access/delegation.ts new file mode 100644 index 0000000..9f7cb9e --- /dev/null +++ b/src/access/delegation.ts @@ -0,0 +1,89 @@ +import type { AutomergeRepoKeyhiveBase } from "@automerge/automerge-repo-keyhive"; +import { bytesToHex, hexToBytes } from "../bytes.js"; +import type { KeyhiveRuntime } from "../runtime.js"; + +/** + * Whether documents delegate to an identity at a required level. + * + * The three values are deliberately not two. `insufficient` is reachable + * only when a delegation naming the identity was found and every one fell + * below the minimum, so it can never mean "not a member" — though it reads + * that way if you skim it. Everything else that is not a clear yes is + * `unknown`: a document this device has not synced, and an identity whose + * access routes through a group, are both the absence of an answer rather + * than a negative one. + * + * Collapsing `unknown` into `insufficient` is the same error as reporting a + * DNS name that could not be resolved as a mismatch. Absence of evidence is + * not evidence of absence. + */ +export type DelegationVerdict = "delegates" | "insufficient" | "unknown"; + +export interface DocumentDelegationOptions { + /** + * The least access that counts, as `Access.fromString` accepts it. Admin + * by default: controlling a document is what owning it means. + */ + minimumAccess?: string; +} + +/** + * Does any of these documents delegate to this identity at `minimumAccess`? + * + * A plain question about keyhive documents and keyhive identities: no DNS, + * no directory, no presentation. Callers compose it into whatever larger + * question they are asking — including DNS name verification, where the + * documents come from a domain's `_onomancy` record. + * + * Only each document's own delegations are consulted. An identity holding + * access through a nested group is `unknown`, not `insufficient`, because + * keyhive's `members()` reports a document's own delegations and those do + * not change when a group that already has access gains a member. Resolving + * that needs transitive delegations *with* their capabilities, which no + * current API exposes: `cgkaMembers()` returns bare `Identifier`s, and + * `Identifier` carries no access level at all. + * + * @example + * ```ts + * const verdict = await documentDelegatesTo(runtime, hive, [docId], userId); + * if (verdict === "delegates") grantSomething(); + * ``` + */ +export async function documentDelegatesTo( + runtime: KeyhiveRuntime, + hive: AutomergeRepoKeyhiveBase, + documentIds: string[], + identityId: string, + options: DocumentDelegationOptions = {} +): Promise { + const wanted = bareId(identityId); + const minimum = runtime.Access.fromString(options.minimumAccess ?? "admin"); + + let anyHeld = false; + let anyDirectMember = false; + + for (const documentId of documentIds) { + const document = await hive.keyhive.getDocument( + new runtime.DocumentId(hexToBytes(bareId(documentId))) + ); + if (!document) continue; + anyHeld = true; + + for (const capability of await document.members()) { + const memberId = bytesToHex(capability.who.id.toBytes()); + if (memberId !== wanted) continue; + anyDirectMember = true; + if (capability.can.atLeast(minimum)) return "delegates"; + } + } + + // Only a direct delegation below the minimum is insufficient. An unheld + // document proves nothing, and neither does absence from the direct + // members: access may route through a group this check does not walk. + return anyHeld && anyDirectMember ? "insufficient" : "unknown"; +} + +/** Hex ids without an `0x` prefix, lowercased, for comparison. */ +export function bareId(id: string): string { + return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); +} diff --git a/src/components/AccountView.tsx b/src/components/AccountView.tsx index c08d209..28b842f 100644 --- a/src/components/AccountView.tsx +++ b/src/components/AccountView.tsx @@ -16,6 +16,12 @@ export interface AccountViewProps { * verifying directory. */ showDnsName?: boolean; + /** + * Canonicalise and validate a typed DNS name claim. Forwarded to + * `ProfileEditor`; pass `runtime.normalizeDnsName` from + * `@automerge/keyhive-react/onomancy` to reject bad claims at entry. + */ + normalizeDnsName?: (raw: string) => string; /** * Publish the contact card into the directory so someone who finds this * account by name can share with it without needing a new contact card. @@ -38,6 +44,7 @@ export function AccountView({ onCancel, showIdentifiers = true, showDnsName = true, + normalizeDnsName, publishContactCard = false, fallbackAvatarSrc, className = "", @@ -50,6 +57,7 @@ export function AccountView({ kind="individual" peerId={self.peerId} showDnsName={showDnsName} + {...(normalizeDnsName ? { normalizeDnsName } : {})} contactCardJson={publishContactCard ? self.contactCardJson : undefined} namePlaceholder="Enter your name" onSaved={onSaved} diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 4f49208..7110235 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -1,7 +1,6 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useDirectory, useDirectoryEntry } from "../directory/context.js"; import type { DirectoryEntry, DirectoryEntryKind } from "../directory/types.js"; -import { normalizeDnsName } from "../onomancy/runtime.js"; import { Avatar } from "./primitives/Avatar.js"; import { DnsNameBadge } from "./primitives/DnsNameBadge.js"; @@ -21,6 +20,21 @@ export interface ProfileEditorProps { * domain's DNSSEC-protected `_onomancy` TXT record. */ showDnsName?: boolean; + /** + * Canonicalise a typed claim, throwing on anything that is not a DNS + * name; the message is shown to the user and nothing is published. + * + * Pass `runtime.normalizeDnsName` from + * `@automerge/keyhive-react/onomancy` to reject bad claims at entry + * against the real grammar. Without it this field still canonicalises + * spelling — trimming, lowercasing, dropping a leading `@` and a trailing + * dot — but cannot tell a hostname from a typo, and an unparseable claim + * is stored and later rendered `invalid` by whatever verifies it. + * + * Spelling is presentation; grammar is not. This component owns the first + * and takes the second from you. + */ + normalizeDnsName?: (raw: string) => string; dnsNameLabel?: string; dnsNamePlaceholder?: string; saveLabel?: string; @@ -34,6 +48,17 @@ export interface ProfileEditorProps { className?: string; } +/** + * Spelling, not grammar: what a field can canonicalise without knowing what + * a hostname is. The default when no `normalizeDnsName` is supplied. + */ +function canonicaliseSpelling(raw: string): string { + let claim = raw.trim().toLowerCase(); + if (claim.startsWith("@")) claim = claim.slice(1); + if (claim.endsWith(".")) claim = claim.slice(0, -1); + return claim; +} + /** * Edit the name and avatar the directory holds for one keyhive id. * @@ -47,6 +72,7 @@ export function ProfileEditor({ nameLabel = "Name", namePlaceholder = "Enter a name", showDnsName = false, + normalizeDnsName = canonicaliseSpelling, dnsNameLabel = "DNS name", dnsNamePlaceholder = "@example.com", saveLabel = "Save", diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index c5370a0..981aabc 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -9,6 +9,16 @@ import type { * The reserved top-level key onomancy namestore data lives under when the * directory document doubles as a root namestore. Never a directory entry: * profile entries and namestore edges share the document without colliding. + * + * Co-location is a layout choice, not a requirement, and the choice is + * constrained by where the document's id came from. Only a self-certifying + * ed25519 document id can anchor a domain — onomancy rejects a legacy + * 16-byte Automerge id outright — so a directory document created through + * `repo.create2` can host both profile entries and namestore edges, while + * one carrying a legacy id can never be a `p=` target and needs the + * namestore kept separately. This filtering is correct either way: it costs + * nothing under the separate layout and is load-bearing under the shared + * one. */ export const RESERVED_ONOMANCY_KEY = "onomancy"; diff --git a/src/directory/types.ts b/src/directory/types.ts index 2b64b2f..63c02f8 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -15,6 +15,33 @@ export type DirectoryEntryKind = "individual" | "group"; * - `unsynced`: the domain designates a document this device has not synced, * so membership cannot be checked yet. Also proves nothing either way. * - `invalid`: the claim is not a DNS name at all. + * + * ## Rules for anyone producing a status + * + * This library renders these six values; it does not require that it be the + * thing that computes them. Applications that verify claims themselves must + * follow the same rules, because the badge means the same thing to a reader + * whichever code produced it. + * + * 1. **`mismatch` requires a record that designates somebody.** A hostname + * that resolves and DNSSEC-validates but whose records all fail strict + * `v=ONO0` parsing proves nothing about any identity, so it is + * `unreachable`. This is not hypothetical: a live record was once a + * hex-encoded `g=` field followed by a truncated `p=`, and every parse + * rejected. Reporting that as `mismatch` would accuse the claimant on + * the strength of somebody's typo. + * 2. **`unreachable` and `unsynced` are the absence of an answer**, never a + * weak `mismatch`. Do not collapse them into it, and do not collapse + * them into each other: the first means the DNS layer said nothing, the + * second that it spoke and the local device cannot yet check the reply. + * 3. **`verified` requires positive evidence**, never the absence of + * contrary evidence. + * 4. **Do not invent a seventh value.** A status outside this set has no + * rendering and no agreed meaning. + * + * The errors this design tolerates run one way: it will not wrongly verify, + * and it will sometimes fail to verify someone legitimate. Preserve that + * asymmetry — it is what makes a badge worth trusting. */ export type DnsNameStatus = "pending" | "verified" | "mismatch" | "unreachable" | "unsynced" | "invalid"; @@ -40,9 +67,13 @@ export interface DirectoryEntry { */ dnsName?: string; /** - * Set by a verifying directory (see `createOnomancyDirectory`), never - * stored. Absent when the entry claims no DNS name or the directory does - * not verify. + * Set by whatever verifies claims — `createOnomancyDirectory` from + * `@automerge/keyhive-react/onomancy`, or the application's own + * equivalent. A decoration, never stored: directories strip it on + * publish. Absent when the entry claims no DNS name, or when nothing in + * scope verifies. + * + * Producers must follow the rules on {@link DnsNameStatus}. */ dnsNameStatus?: DnsNameStatus; } diff --git a/src/index.ts b/src/index.ts index 604d67d..245acc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,29 +29,16 @@ export type { } from "./directory/automerge-directory.js"; export { useAutomergeDocDirectory } from "./directory/useAutomergeDocDirectory.js"; -export { - createOnomancyRuntime, - normalizeDnsName, - parseRecordDocId, -} from "./onomancy/runtime.js"; -export type { - HostnameBinding, - OnomancyModule, - OnomancyRuntime, - OnomancyRuntimeOptions, -} from "./onomancy/runtime.js"; -export { - createKeyhiveDesignation, - idEqualityDesignation, -} from "./onomancy/designation.js"; +// DNS name verification lives in `@automerge/keyhive-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 type { - DesignationVerdict, - DnsDesignation, - KeyhiveDesignationOptions, -} from "./onomancy/designation.js"; -export { createOnomancyDirectory } from "./onomancy/verified-directory.js"; -export type { OnomancyDirectoryOptions } from "./onomancy/verified-directory.js"; -export { useOnomancyDirectory } from "./onomancy/useOnomancyDirectory.js"; + DelegationVerdict, + DocumentDelegationOptions, +} from "./access/delegation.js"; export { agentKindOf, diff --git a/src/onomancy/designation.ts b/src/onomancy/designation.ts index e7b88a3..d676096 100644 --- a/src/onomancy/designation.ts +++ b/src/onomancy/designation.ts @@ -1,11 +1,21 @@ import type { AutomergeRepoKeyhiveBase } from "@automerge/automerge-repo-keyhive"; -import { bytesToHex, hexToBytes } from "../bytes.js"; +import { + bareId, + documentDelegatesTo, + type DocumentDelegationOptions, +} from "../access/delegation.js"; import type { DirectoryEntry } from "../directory/types.js"; import type { KeyhiveRuntime } from "../runtime.js"; /** * Whether a DNS binding's root documents designate an identity. * + * Deliberately *not* the same type as `DelegationVerdict`, though both have + * three values. `excludes` here means the domain designates somebody else, + * which is a different fact from a delegation existing below the required + * level — and the two collapse into one only at this boundary, where every + * keyhive reason for "not them" becomes the single DNS answer "not them". + * * `unknown` is for verdicts the local device cannot reach: the designated * document exists but is not held here, so membership can be checked only * after a sync. It is not evidence in either direction. @@ -26,69 +36,57 @@ export type DnsDesignation = ( /** * The solo case: the bound id is the identity itself. This is the default, * and the right check when accounts anchor domains directly to their key. + * + * A bound id that is somebody else's `excludes`, because a record naming a + * different identity is a positive statement about who the domain means. */ export const idEqualityDesignation: DnsDesignation = (entry, boundIds) => - boundIds.includes(bareId(entry.id)) ? "designates" : "excludes"; + boundIds.map(bareId).includes(bareId(entry.id)) ? "designates" : "excludes"; -export interface KeyhiveDesignationOptions { - /** - * The least access that counts as the domain designating someone, as - * `Access.fromString` accepts it. Admin by default: controlling the root - * namestore document is what owning the name means. - */ - minimumAccess?: string; -} +export type KeyhiveDesignationOptions = DocumentDelegationOptions; /** * Designation through keyhive: the domain binds a root namestore document, - * and the identities the document delegates admin access to are the ones it + * and the identities that document delegates admin access to are the ones it * designates. Ownership is shared by inviting more admins; the DNS record * never changes. * + * A composition, not an implementation. The DNS half is here; the keyhive + * half is {@link documentDelegatesTo}, which knows nothing about domains. + * * The solo case is included: a bound id that is the identity itself * designates directly, so anchors of either shape verify. * - * Only the document's own delegations are consulted, so an identity holding - * admin through a nested group is `unknown` here, not excluded. A document - * this device has not synced is `unknown` too. + * Inherits {@link documentDelegatesTo}'s limit — an identity holding admin + * through a nested group reads `unknown`, never `excludes`. */ export function createKeyhiveDesignation( runtime: KeyhiveRuntime, hive: AutomergeRepoKeyhiveBase, options: KeyhiveDesignationOptions = {} ): DnsDesignation { - const level = options.minimumAccess ?? "admin"; - return async (entry, boundIds) => { - const entryId = bareId(entry.id); - if (boundIds.includes(entryId)) return "designates"; + const identityId = bareId(entry.id); + // The domain may anchor the key directly rather than a document. + if (boundIds.map(bareId).includes(identityId)) return "designates"; - const minimum = runtime.Access.fromString(level); - let anyHeld = false; - let anyDirectMember = false; + const verdict = await documentDelegatesTo( + runtime, + hive, + boundIds, + identityId, + options + ); - for (const boundId of boundIds) { - const document = await hive.keyhive.getDocument( - new runtime.DocumentId(hexToBytes(boundId)) - ); - if (!document) continue; - anyHeld = true; - - for (const capability of await document.members()) { - const memberId = bytesToHex(capability.who.id.toBytes()); - if (memberId !== entryId) continue; - anyDirectMember = true; - if (capability.can.atLeast(minimum)) return "designates"; - } + // Every keyhive reason for "not them" is the one DNS answer "not them"; + // "no answer" stays "no answer" across the boundary. + switch (verdict) { + case "delegates": + return "designates"; + case "insufficient": + return "excludes"; + default: + return "unknown"; } - - // Only a direct delegation below the minimum excludes. An unheld - // document proves nothing, and neither does absence from the direct - // members: access may route through a group this check does not walk. - return anyHeld && anyDirectMember ? "excludes" : "unknown"; }; } - -function bareId(id: string): string { - return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); -} diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts new file mode 100644 index 0000000..f343167 --- /dev/null +++ b/src/onomancy/index.ts @@ -0,0 +1,51 @@ +/** + * DNS name verification through onomancy. + * + * Imported as `@automerge/keyhive-react/onomancy`, separately from the main + * entry point. The split follows the domains rather than the layers: the + * main entry knows what a DNS name claim is and what the six statuses mean, + * and renders them; everything that *resolves* a name lives here. + * + * That makes this subpath optional in practice. An application that + * computes `dnsNameStatus` itself — because it already holds onomancy, or + * because it verifies against something other than DNS — can use the + * components and the `DirectoryEntry` fields without importing any of this. + * The rules such an application must follow are on `DnsNameStatus`, which + * is on the main entry precisely so it binds either way. + * + * The isolation guarantee is unchanged and unweakened: like the main entry, + * nothing here imports anything but React. The onomancy Wasm arrives by + * injection through {@link createOnomancyRuntime}, so the host application + * still owns the only instance. + */ + +export { createOnomancyRuntime, parseRecordDocId } from "./runtime.js"; +export type { + HostnameBinding, + OnomancyModule, + OnomancyName, + OnomancyRuntime, + OnomancyRuntimeOptions, +} from "./runtime.js"; + +export { + createKeyhiveDesignation, + idEqualityDesignation, +} from "./designation.js"; +export type { + DesignationVerdict, + DnsDesignation, + KeyhiveDesignationOptions, +} from "./designation.js"; + +export { + clearVerificationCache, + createOnomancyDirectory, + createVerificationCache, +} from "./verified-directory.js"; +export type { + OnomancyDirectoryOptions, + VerificationCache, +} from "./verified-directory.js"; + +export { useOnomancyDirectory } from "./useOnomancyDirectory.js"; diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index ad873dd..4290f25 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -1,5 +1,22 @@ import { bytesToHex } from "../bytes.js"; +/** + * A parsed onomancy name. Structurally `@inkandswitch/onomancy`'s `Name`, + * declared here so this package needs no import of its own. + */ +export interface OnomancyName { + /** The anchor in printed form: `~`, `@expede.wtf`, or `automerge:…`. */ + readonly anchor: string; + /** The trust anchor kind: `"local"`, `"dns"`, or `"doc"`. */ + readonly anchorKind: string; + /** The path segments, one edge hop each. */ + readonly segments: string[]; + /** The canonical printed form. */ + readonly value: string; + /** Wasm handles are disposable; called when present. */ + free?(): void; +} + /** * 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 @@ -12,6 +29,14 @@ export interface OnomancyModule { * `{ hostname, links, freshness, records: string[] }`. */ resolveHostname(hostname: string, dohUrl?: string | null): Promise; + + /** + * The name grammar itself. Parsing claims through it rather than by hand + * means this package cannot drift from the spec: canonicalisation, + * dotless names, IP literals and label rules are all decided by the same + * code that decides them everywhere else. + */ + Name: new (raw: string) => OnomancyName; } export interface OnomancyRuntimeOptions { @@ -40,21 +65,60 @@ export interface OnomancyRuntime { * Rejects on malformed hostnames, transport failures, and invalid chains. */ resolveBoundIds(hostname: string): Promise; + + /** + * A claimed DNS name in canonical form: lowercased, with the leading `@` + * and any trailing dot removed. + * + * Throws on anything the onomancy grammar rejects as a DNS anchor — + * dotless names, IP literals, malformed labels — and on a claim that + * carries path segments, since a claim names a host and not a path. + */ + normalizeDnsName(raw: string): string; } -/** Build a runtime from the application's own onomancy import. */ +/** + * Build a runtime from the application's own onomancy import. + * + * Every member is an arrow function closing over `onomancy`, never a method + * reading `this`. That is deliberate and load-bearing: consumers pass these + * detached — `normalizeDnsName` goes to `ProfileEditor` as a prop — and a + * member that grew a `this` would break every such call site at runtime + * with nothing at the type level to warn them. Arrow functions have no own + * `this`, so the mistake cannot be made here rather than merely not having + * been made yet. + */ export function createOnomancyRuntime( onomancy: OnomancyModule, options: OnomancyRuntimeOptions = {} ): OnomancyRuntime { return { - async resolveBoundIds(hostname) { + resolveBoundIds: async (hostname) => { const outcome = await onomancy.resolveHostname( hostname, options.dohUrl ?? null ); return { hostname, ids: boundIdsOf(outcome) }; }, + + normalizeDnsName: (raw) => { + const trimmed = raw.trim(); + // The grammar requires a sigil; a claim is stored without one. + const spelled = trimmed.startsWith("@") ? trimmed : `@${trimmed}`; + + const name = new onomancy.Name(spelled); + try { + if (name.anchorKind !== "dns") { + throw new Error(`Not a DNS name: "${raw}"`); + } + if (name.segments.length > 0) { + throw new Error(`A DNS name claim cannot have a path: "${raw}"`); + } + return name.anchor.slice(1); + } finally { + name.free?.(); + } + }, }; } @@ -76,6 +140,9 @@ function boundIdsOf(outcome: unknown): string[] { * The hex-encoded root document id of one TXT record, or `undefined` when the * record 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. + * + * 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. */ export function parseRecordDocId(record: string): string | undefined { const match = record.match( @@ -94,33 +161,3 @@ function base64ToBytes(base64: string): Uint8Array | undefined { return undefined; } } - -/** - * Parse a claimed DNS name into its canonical form: lowercase, leading `@` - * and trailing dot stripped. Throws on names the DNS anchoring grammar - * rejects, such as dotless names and IP literals. - */ -export function normalizeDnsName(raw: string): string { - let name = raw.trim().toLowerCase(); - if (name.startsWith("@")) name = name.slice(1); - if (name.endsWith(".")) name = name.slice(0, -1); - - if (name.length === 0 || name.length > 253) { - throw new Error(`Not a DNS name: "${raw}"`); - } - const labels = name.split("."); - // A dotless name is a flat parse error, never a hostname. - if (labels.length < 2) { - throw new Error(`A DNS name needs at least one dot: "${raw}"`); - } - for (const label of labels) { - if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label) || label.length > 63) { - throw new Error(`Not a DNS label: "${label}" in "${raw}"`); - } - } - // IP literals are rejected under `@`. - if (labels.every((label) => /^\d+$/.test(label))) { - throw new Error(`IP literals cannot be onomancy names: "${raw}"`); - } - return name; -} diff --git a/src/onomancy/useOnomancyDirectory.ts b/src/onomancy/useOnomancyDirectory.ts index 289be98..c8156b4 100644 --- a/src/onomancy/useOnomancyDirectory.ts +++ b/src/onomancy/useOnomancyDirectory.ts @@ -1,23 +1,41 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import type { NameDirectory } from "../directory/types.js"; import type { OnomancyRuntime } from "./runtime.js"; import { createOnomancyDirectory, + createVerificationCache, type OnomancyDirectoryOptions, + type VerificationCache, } from "./verified-directory.js"; /** - * `createOnomancyDirectory` memoized on the base directory, so verification - * results are re-checked when the base directory's identity changes. + * `createOnomancyDirectory` with its verification results kept safe across + * rebuilds. + * + * The wrapper is still memoized on `base`, so a directory backed by a live + * Automerge document is rebuilt on every write — the document is a new + * object each time. What no longer happens is the rebuild throwing away + * every verdict with it: the cache lives in a ref, outlives the wrapper, and + * so a rebuild costs a wrapper allocation rather than a fresh DoH round trip + * per claimed hostname. + * + * Pass `options.cache` to share results more widely, or to hold the handle + * you need for `clearVerificationCache`. */ export function useOnomancyDirectory( base: NameDirectory, runtime: OnomancyRuntime, options: OnomancyDirectoryOptions = {} ): NameDirectory { - const { designation, notice } = options; + const { designation, notice, cache: provided } = options; + + const held = useRef(null); + if (held.current === null) held.current = createVerificationCache(); + const cache = provided ?? held.current; + return useMemo( - () => createOnomancyDirectory(base, runtime, { designation, notice }), - [base, runtime, designation, notice] + () => + createOnomancyDirectory(base, runtime, { designation, notice, cache }), + [base, runtime, designation, notice, cache] ); } diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index 3fa3181..6d82c79 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -8,7 +8,7 @@ import { type DesignationVerdict, type DnsDesignation, } from "./designation.js"; -import { normalizeDnsName, type OnomancyRuntime } from "./runtime.js"; +import type { OnomancyRuntime } from "./runtime.js"; type Resolution = | { phase: "pending" } @@ -18,6 +18,62 @@ type Resolution = type Verdict = { phase: "pending" } | { phase: "done"; verdict: DesignationVerdict }; +interface CacheState { + resolutions: Map; + verdicts: Map; + listeners: Set<() => void>; +} + +/** + * Verification results held across directory rebuilds. + * + * A directory backed by a live Automerge document is never referentially + * stable — the document is a new object after every change, so anything + * memoized on it is rebuilt on every write. A cache held inside the + * directory would therefore be discarded on every write, re-resolving every + * claimed hostname over DoH and abandoning whatever was already in flight. + * Hoisting the cache out of the directory is what makes rebuilds free. + * + * Opaque on purpose: hold one, pass it in, and clear it when you want a + * re-check. {@link useOnomancyDirectory} keeps one for you. + */ +export interface VerificationCache { + readonly kind: "onomancy-verification-cache"; +} + +const states = new WeakMap(); + +/** A cache with nothing in it yet. */ +export function createVerificationCache(): VerificationCache { + const cache: VerificationCache = { kind: "onomancy-verification-cache" }; + states.set(cache, { + resolutions: new Map(), + verdicts: new Map(), + listeners: new Set(), + }); + return cache; +} + +/** + * Forget every resolution and verdict, so the next read verifies again. + * + * Subscribers are notified, since every claimed name reverts to `pending`. + * Live subscriptions survive; only results are dropped. + */ +export function clearVerificationCache(cache: VerificationCache): void { + const state = states.get(cache); + if (!state) return; + state.resolutions.clear(); + state.verdicts.clear(); + for (const listener of state.listeners) listener(); +} + +function stateOf(cache: VerificationCache): CacheState { + const state = states.get(cache); + if (!state) throw new Error("Not a verification cache from this module"); + return state; +} + export interface OnomancyDirectoryOptions { /** * Decides whether the bound root documents designate an entry's identity. @@ -26,6 +82,12 @@ export interface OnomancyDirectoryOptions { * namestore document whose admins own the name. */ designation?: DnsDesignation; + /** + * Where verification results live. Defaults to a fresh cache, which means + * results last exactly as long as this directory does. Hoist one to keep + * them across rebuilds — {@link useOnomancyDirectory} does. + */ + cache?: VerificationCache; /** Overrides the base directory's notice. */ notice?: string; } @@ -34,8 +96,7 @@ export interface OnomancyDirectoryOptions { * Wrap a directory so entries that claim a DNS name (`entry.dnsName`) carry a * verification status (`entry.dnsNameStatus`). * - * Verification is two layers, checked lazily the first time an entry is read - * and cached for the directory's lifetime (build a fresh one to re-check): + * Verification is two layers, checked lazily the first time an entry is read: * * 1. DNS: the hostname's `_onomancy` TXT record is fetched over DoH and * validated by DNSSEC from the IANA root, yielding root document ids. @@ -43,8 +104,14 @@ export interface OnomancyDirectoryOptions { * the bound id must be the identity itself; a keyhive designation accepts * admins of a shared root document instead. * - * Subscribers are notified when a check lands, so a `DirectoryProvider` - * re-renders with the result. + * Results go in the cache from `options.cache`, keyed by hostname and by + * `(hostname, identity)`, so a rebuilt directory sharing that cache neither + * re-resolves what is known nor re-issues what is already in flight. + * Subscribers are notified when a check lands — including subscribers that + * arrived after it started, which is what stops a rebuild from stranding a + * result nobody is listening for. + * + * Statuses follow the rules on {@link DnsNameStatus}. */ export function createOnomancyDirectory( base: NameDirectory, @@ -52,9 +119,9 @@ export function createOnomancyDirectory( options: OnomancyDirectoryOptions = {} ): NameDirectory { const designation = options.designation ?? idEqualityDesignation; - const resolutions = new Map(); - const verdicts = new Map(); - const listeners = new Set<() => void>(); + const cache = options.cache ?? createVerificationCache(); + const { resolutions, verdicts, listeners } = stateOf(cache); + const notify = () => { for (const listener of listeners) listener(); }; @@ -116,7 +183,7 @@ export function createOnomancyDirectory( let hostname: string; try { - hostname = normalizeDnsName(entry.dnsName); + hostname = runtime.normalizeDnsName(entry.dnsName); } catch { return { ...entry, dnsNameStatus: "invalid" }; } @@ -158,6 +225,8 @@ export function createOnomancyDirectory( }, subscribe(listener) { + // Into the cache's set, not this directory's: a check started before a + // rebuild must still reach whoever is listening when it lands. listeners.add(listener); const unsubscribe = base.subscribe?.(listener); return () => { diff --git a/src/runtime.ts b/src/runtime.ts index 09b2470..de3188a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -36,7 +36,21 @@ export interface KeyhiveRuntime { /** The subset of ARK's exports the runtime reads. */ export type KeyhiveModule = KeyhiveRuntime; -/** Build a runtime from the application's own ARK import. */ +/** + * Build a runtime from the application's own ARK import. + * + * Every *function* member is an arrow closing over `ark`, never a method + * reading `this`. That is deliberate and load-bearing: consumers pass these + * detached, and a member that grew a `this` would break every such call + * site at runtime with nothing at the type level to warn them. Arrow + * functions have no own `this`, so the mistake cannot be made here rather + * than merely not having been made yet. The same rule holds for + * `createOnomancyRuntime`. + * + * `Access`, `ContactCard`, `DocumentId` and `Identifier` are class + * references rather than functions, so the concern does not apply to them + * and they need no conversion. + */ export function createKeyhiveRuntime(ark: KeyhiveModule): KeyhiveRuntime { return { Access: ark.Access, From 1603c3655d34a5cfcdc54a0f82fabe6d46062045 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:41 -0700 Subject: [PATCH 04/16] Harden verification: split statuses by remedy, add the serial ratchet DnsNameStatus grows from six values to twelve, organised by what the reader should do: a typo, a domain making no claim, and a zone whose records failed validation previously all rendered as "could not reach", sending users to retry over faults retrying cannot fix. Record selection now parses all TXT records, takes the highest serial (BigInt: the space is u64 and Number equates neighbours near the top), and surfaces same-serial disagreement as contested rather than picking. The serial ratchet remembers the highest serial accepted per name so a stale chain bearing a lower one is refused as replayed. Deferral precedes movement: records dated past the skew bound are set aside before selection, since a ratchet without the bound is jammed by one forged serial. A fresh chain may move the ratchet in either direction, which is what lets a poisoned ratchet heal. The ratchet survives cache clears deliberately: a replay defence that revalidation erases defends nothing. Also: freshness gains magnitude (lapsedSeconds) and clock-skew fields; failure classification reads the runtime's typed reason rather than message text, falling back conservatively for older builds; listMembers unions transitive members with direct ones, closing a measured invisible-Admin path through the generated owner group; the verified badge copy states that the check is one-directional (no certificate is consulted); both apps carry a matching disclosure banner. --- .gitignore | 3 + .prettierignore | 9 +- README.md | 41 ++- apps/component-test-app/package.json | 2 +- apps/component-test-app/src/App.tsx | 37 ++ e2e/dns-names.spec.ts | 12 +- eslint.config.mjs | 9 + pnpm-lock.yaml | 13 +- scripts/check-prefix.mjs | 15 +- src/access/targets.ts | 40 ++- src/components/AccessEditor.tsx | 3 + src/components/ContactBook.tsx | 3 + src/components/ProfileEditor.tsx | 3 + src/components/primitives/DnsNameBadge.tsx | 129 ++++++- src/directory/types.ts | 148 +++++++- src/onomancy/index.ts | 2 + src/onomancy/runtime.ts | 370 +++++++++++++++++++- src/onomancy/useOnomancyDirectory.ts | 47 ++- src/onomancy/verified-directory.ts | 379 +++++++++++++++++++-- 19 files changed, 1177 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 0e237c8..d6f65fa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ playwright-report blob-report .pnpm-store .ignore +.pi-subagents +result +result-* diff --git a/.prettierignore b/.prettierignore index bc3946c..5cd128d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,10 @@ +# Prettier discovers files rather than opting in to them, and does not read +# .gitignore. Scratch and build directories therefore have to be listed here +# as well as there. See the matching list in eslint.config.mjs. +.ignore +.pi-subagents +.pnpm-store dist pnpm-lock.yaml -.pnpm-store +result +result-* diff --git a/README.md b/README.md index 597132c..bfe15fd 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,12 @@ function App({ baseDirectory }) { ``` A claim is checked once, lazily, the first time its entry is read, and the -result lands on the entry as `dnsNameStatus`: `verified`, `mismatch`, -`unreachable`, `unsynced`, `pending`, or `invalid`. `ContactBook`, +result lands on the entry as `dnsNameStatus`, one of twelve values — +`verified`, `mismatch`, `contested`, `offline`, `malformed`, `no-claim`, +`chain-failed`, `replayed`, `deferred`, `unsynced`, `pending`, `invalid`. +The five non-answers are separate values because they carry different +remedies: retry, fix the input, tell the domain owner, wait for a clock, or +trust nothing from this zone. `ContactBook`, `AccessEditor`, and `ProfileEditor` render the claim as a `DnsNameBadge`; a directory without the wrapper renders claims as exactly that — claims, visually no stronger than a self-asserted display name. @@ -188,7 +192,7 @@ it can write anything into it, including somebody else's domain. That is fine: > A claim is forgeable. A badge is not. Anyone can write > `dnsName: "example.com"` into anyone's entry, but the badge is not read from > the document — it comes from resolving the domain and checking what that -> domain designates. A forged claim renders `mismatch` or `unreachable`. +> domain designates. A forged claim renders `mismatch` or `no-claim`. > Nobody can write their way to `verified`. > > The document carries the assertion. DNS carries the authority. @@ -200,16 +204,39 @@ to keep claims, and why `publish` strips `dnsNameStatus` before writing. ### The errors run one way A verified badge proves that the domain, as attested by a DNSSEC chain from -the IANA root during the chain's signature window, designated this identity. -It proves nothing about the domain owner's intentions, and nothing about any -other name. +the IANA root during the chain's signature window, designated a document +**this identity administers**. It proves nothing about the domain owner's +intentions, and nothing about any other name. + +It is **one-directional, and it is not the onomancy spec's _verified +binding_.** The spec's binding runs through a certificate: the domain names +the document whose id appears _in the certificate_, and a key delegated by +that document _signed_ it. This library consults no certificate. + +Two consequences, neither of them merely "weaker evidence": + +- **It is not transferable.** A certificate is self-authenticating — anyone + can check it against their own trust anchors, from bytes that arrived + anywhere. This verdict is local: it needs the document replicated and + keyhive state present, so a third party cannot be shown why the badge was + earned. +- **The document never speaks.** A domain may unilaterally name any document + id, and that document's admins cannot decline. Under the spec the document + participates, and refusing to sign is how it refuses. Here the only thing + preventing a badge is the identity not claiming the name. + +So a document can carry a certificate while none of its admins claim the +domain, and an identity can carry this badge while the document has certified +nothing. **Different questions about the same pair.** See `DnsNameStatus` for +the full statement, and do not overload `verified` when certificates become +mintable — that verdict wants its own status. The design has **no false positives and real false negatives**, deliberately: - It will not wrongly verify. Every path to `verified` requires positive evidence from outside the document. - It will sometimes fail to verify someone legitimate. A record that fails to - parse reads `unreachable`; a designated document this device has not synced + parse reads `no-claim`; a designated document this device has not synced reads `unsynced`; and an identity holding admin _through a group_ reads `unsynced` too, because keyhive's `members()` reports a document's own delegations and those do not change when a group that already has access diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index 6875b10..8534006 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", "@automerge/keyhive-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", - "@inkandswitch/onomancy": "0.1.0", + "@inkandswitch/onomancy": "link:../../../onomancy/onomancy_wasm", "@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 cea1ce3..5d3b38c 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -519,6 +519,20 @@ function NamesSection({ const [query, setQuery] = useState(""); const [outcome, setOutcome] = useState(null); + // The hostname this outcome came from, captured at submit rather than read + // from `query` at render. The input keeps taking keystrokes while the + // resolve is in flight, so reading it later would caption one result with + // another name — and this caption makes a security claim, so a mismatched + // hostname would be a lie rather than a cosmetic slip. + // + // This is the WEAKER of the two available fixes, and deliberately marked as + // such. keyhive-todo-app-demo derives both the caption and the document it + // captions from a single route object read in one render, so the two cannot + // disagree — the mismatch is unrepresentable. Here it is merely prevented, + // and prevention depends on the next reader noticing why this state exists + // rather than reaching for `query`, which nothing in the code stops them + // doing. Treat it as a guarded problem, not a solved one. + const [resolvedHostname, setResolvedHostname] = useState(null); const [resolveError, setResolveError] = useState(null); const [resolving, setResolving] = useState(false); @@ -583,6 +597,12 @@ function NamesSection({ if (!raw) return; setResolveError(null); setOutcome(null); + // Only a `@hostname` root makes a DNS claim. `~` and bare paths + // start from our own directory, which asserts nothing about a + // domain. + setResolvedHostname( + raw.startsWith("@") ? raw.slice(1).split("/")[0]! : null + ); setResolving(true); onResolve(raw) .then(setOutcome) @@ -618,6 +638,23 @@ function NamesSection({

)} + {outcome?.status === "resolved" && resolvedHostname !== null && ( + // Shown only for a resolved `@hostname` route. DNS got us to a + // document; it did not show that the document accepts the domain — + // that needs the onomancy certificate, and there is no JS API to + // obtain one. Certificates travel inside the bound document and + // arrive by replication, so the honest verb is *hold*, not *fetch*: + // there is no retrieval anywhere in the design to not-yet-do. + // + // Worded identically in keyhive-todo-app-demo. Two apps disagreeing + // about what the same unproven thing means is worse than either + // wording alone. +

+ Resolved through DNS. Nothing here proves this document accepts{" "} + {resolvedHostname} — that check needs the onomancy + certificate, which this app does not yet hold. +

+ )} {outcome?.status === "partial" && (

Partial: consumed {outcome.consumed} of {outcome.total} segment(s), diff --git a/e2e/dns-names.spec.ts b/e2e/dns-names.spec.ts index 9787c3d..ee33b1a 100644 --- a/e2e/dns-names.spec.ts +++ b/e2e/dns-names.spec.ts @@ -24,7 +24,17 @@ test.describe("DNS names verified through onomancy", () => { await claimDnsName(page, "@self.test"); const claimed = badge(page, "@self.test"); await expect(claimed).toBeVisible(); - await expect(claimed).toHaveAttribute("title", /DNSSEC-verified/); + // Asserts the CAVEAT, not the brand word. The tooltip previously read + // "DNSSEC-verified: this domain designates this identity", which was + // one-directional evidence phrased as mutual consent. Locking the + // disclaimer is what stops that regressing: a future edit may reword the + // positive half freely, and must not drop the half that says the + // document has not spoken. + await expect(claimed).toHaveAttribute("title", /DNSSEC-valid/); + await expect(claimed).toHaveAttribute( + "title", + /has not itself asserted this domain/ + ); }); test("a claim of someone else's domain is marked a mismatch", async ({ diff --git a/eslint.config.mjs b/eslint.config.mjs index 951ae69..5a91ec3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,11 +41,20 @@ const automergeSlimImportRule = { export default [ { + // ESLint and Prettier are the only tools in this tree that DISCOVER files + // rather than opting in to them (tsc uses `include`, Playwright `testDir`, + // Tailwind and check-prefix a `src/**` glob). So they are also the only + // two that walk scratch directories, and both need them listed here and in + // .prettierignore. Neither reads .gitignore. ignores: [ "**/*.d.ts", "**/dist/*", "**/node_modules/*", + ".ignore/**", + ".pi-subagents/**", "eslint.config.mjs", + "result", + "result-*", "**/vite.config.ts", ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff8bd8f..abf29f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,10 +11,6 @@ overrides: importers: .: - dependencies: - '@inkandswitch/onomancy': - specifier: ^0.1.0 - version: 0.1.0 devDependencies: '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.6 @@ -89,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.1.0 - version: 0.1.0 + specifier: link:../../../onomancy/onomancy_wasm + version: link:../../../onomancy/onomancy_wasm '@keyhive/keyhive': specifier: 0.1.0-alpha.8 version: 0.1.0-alpha.8 @@ -481,9 +477,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inkandswitch/onomancy@0.1.0': - resolution: {integrity: sha512-hQosbokR9XrGYQkHRNlS/PzPmcVGFpPkRlwlHIQsI/4bvLO2WLiVAEQdkA8Id+aFSc32SKqksUSU3f7RIY5Ykw==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1964,8 +1957,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inkandswitch/onomancy@0.1.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/scripts/check-prefix.mjs b/scripts/check-prefix.mjs index 527b954..2120d9f 100644 --- a/scripts/check-prefix.mjs +++ b/scripts/check-prefix.mjs @@ -9,6 +9,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +18,7 @@ const PACKAGE_DIR = new URL("..", import.meta.url).pathname; // Words that are Tailwind utilities and also ordinary English or JavaScript. // Tailwind's extractor cannot tell `.filter(...)` from a class name. const KNOWN_FALSE_POSITIVES = new Set([ + "collapse", "contents", "ease-out", "filter", @@ -42,9 +44,18 @@ writeFileSync( ); writeFileSync(inputPath, "@tailwind utilities;\n"); +// Resolve Tailwind's CLI through the module graph rather than shelling out to +// `npx`. `npx` reaches it via `node_modules/.bin/tailwindcss`, a shim with a +// `#!/bin/sh` shebang — so on a system without `/bin/sh` this check cannot run +// at all, and `pnpm build` fails on a machine where the toolchain is present. +// Resolving the entry point directly needs no shell and no extra process +// lookup. +const require = createRequire(import.meta.url); +const tailwindCli = require.resolve("tailwindcss/lib/cli.js"); + execFileSync( - "npx", - ["tailwindcss", "-c", configPath, "-i", inputPath, "-o", outputPath], + process.execPath, + [tailwindCli, "-c", configPath, "-i", inputPath, "-o", outputPath], { cwd: PACKAGE_DIR, stdio: "pipe" } ); diff --git a/src/access/targets.ts b/src/access/targets.ts index e0ed2f5..3f359fb 100644 --- a/src/access/targets.ts +++ b/src/access/targets.ts @@ -39,8 +39,13 @@ export interface AccessTarget { runtime: KeyhiveRuntime; supportsPublicAccess: boolean; /** - * Who this target is shared with, one entry per direct delegation. A group - * is one entry (individual group members are not listed here). + * Everyone who holds access, by whatever path. + * + * A direct delegation is one entry, and a group added directly is one entry + * rather than one per member. Individuals reachable *only* through a group + * also appear, marked `isDirect: false` — they hold real access and a list + * that omitted them would be a list that lies, which matters most for the + * revocation flows built on top of it. */ listMembers(): Promise; /** @@ -187,7 +192,7 @@ export function createDocumentTarget( } const docHex = docIdHex(); - return caps + const direct = caps .filter((cap) => !isGeneratedOwnerGroup(cap, docHex)) .map((cap) => { const id = bytesToHex(cap.who.id.toBytes()); @@ -203,6 +208,35 @@ export function createDocumentTarget( kind: isSelf ? ("individual" as const) : agentKindOf(cap.who), }; }); + + // Union, not either-or. + // + // Returning only `direct` whenever it is non-empty drops everyone whose + // access arrives through a group — and `direct` is *always* non-empty, + // because this identity is itself a direct member of any document it + // can see. So the transitive branch above was unreachable in practice. + // + // Measured consequence, not a hypothetical: a document's generated + // owner group holds Admin and is filtered out just above as machinery. + // A person added to that group therefore holds Admin transitively, and + // under the old either-or they appeared in no list this component could + // render — unshown, and unrevokable through a UI that revokes from + // the list it shows. + // + // The group itself stays hidden and its members are shown: the group is + // machinery, its members are people. That does mean a row can appear + // with no shown path to its authority, which `isDirect: false` is + // there to let a renderer explain. + const directIds = new Set(direct.map((member) => member.id)); + const transitiveOnly = reachable + .filter((member) => !directIds.has(member.id)) + .map((member) => ({ + ...member, + isDirect: false, + kind: "individual" as const, + })); + + return [...direct, ...transitiveOnly]; }, async selfAccess() { diff --git a/src/components/AccessEditor.tsx b/src/components/AccessEditor.tsx index 0971881..c070340 100644 --- a/src/components/AccessEditor.tsx +++ b/src/components/AccessEditor.tsx @@ -323,6 +323,9 @@ export function AccessEditor({ )} diff --git a/src/components/ContactBook.tsx b/src/components/ContactBook.tsx index 58dd34c..1877d07 100644 --- a/src/components/ContactBook.tsx +++ b/src/components/ContactBook.tsx @@ -117,6 +117,9 @@ export function ContactBook({ )} diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 7110235..0b74c85 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -244,6 +244,9 @@ export function ProfileEditor({ )} diff --git a/src/components/primitives/DnsNameBadge.tsx b/src/components/primitives/DnsNameBadge.tsx index 93861e5..1322259 100644 --- a/src/components/primitives/DnsNameBadge.tsx +++ b/src/components/primitives/DnsNameBadge.tsx @@ -5,23 +5,92 @@ export interface DnsNameBadgeProps { dnsName: string; /** Absent when the directory in scope does not verify claims. */ status?: DnsNameStatus; + /** + * How current the DNSSEC chain was when the claim was checked. Orthogonal + * to `status` — it qualifies the verdict rather than replacing it. Absent + * when no chain was obtained, which is not the same as failing the axis. + */ + freshness?: "fresh" | "stale" | "deferred"; + /** + * How far the proof had lapsed when checked, in seconds. Only meaningful + * beside `freshness="stale"`, and often absent even then. + */ + lapsedSeconds?: number; className?: string; } +/** + * A coarse, human-scaled age. Deliberately imprecise: the difference that + * matters is hours versus months, and rendering "lapsed 3,847 seconds ago" + * asks the reader to do arithmetic to reach a judgement the phrasing could + * have handed them. + */ +function describeLapse(seconds: number): string { + const hours = seconds / 3600; + if (hours < 1) return "under an hour ago"; + if (hours < 48) return `about ${Math.round(hours)} hours ago`; + + const days = hours / 24; + if (days < 60) return `about ${Math.round(days)} days ago`; + return `about ${Math.round(days / 30)} months ago`; +} + const STATUS_GLYPH: Record = { verified: "\u2713", pending: "\u2026", mismatch: "\u2717", - unreachable: "?", + // A self-contradicting zone is not a refusal and not an absence. It gets + // its own mark so it cannot be read as either. + contested: "\u2260", + offline: "?", + malformed: "!", + "no-claim": "\u2013", + // The only failure here with a security reading, so it is the only one + // that gets a warning mark rather than a neutral one. + "chain-failed": "\u26a0", + replayed: "\u26a0", + deferred: "\u2026", unsynced: "?", invalid: "\u2717", }; const STATUS_TITLE: Record = { - verified: "DNSSEC-verified: this domain designates this identity.", + // States what was checked, which is one direction only. + // + // The old copy read "DNSSEC-verified: this domain designates this + // identity", which a reader takes as mutual. It is not: DNS names a + // document and this identity administers that document. The **document + // has never asserted the domain** — that is the onomancy certificate, and + // nothing here consults one. A domain may unilaterally name any document + // id, and its admins cannot decline; only their own non-claim keeps this + // badge from appearing. + verified: + "This domain's DNS records are DNSSEC-valid and designate a document that this identity administers. The document has not itself asserted this domain — that needs an onomancy certificate, which this check does not consult.", pending: "Checking this domain's DNS binding.", mismatch: "This domain's DNS binding designates a different identity.", - unreachable: "This domain's DNS binding could not be resolved.", + contested: + "This domain publishes two conflicting records of equal precedence, naming different documents. It has not said who it designates, which is not the same as saying it is not this identity.", + offline: + "This domain's DNS binding could not be reached. Nothing is proven either way — try again when you are back online.", + // Names the remedy, and the remedy is not the network. The old copy for + // this case said "could not be resolved", which sent a person to check + // their connection over a typo they could see. + malformed: + "That is not a valid hostname, so no lookup was possible. Check the spelling of the claim.", + "no-claim": + "This domain answered and publishes no usable onomancy record. It is not claiming anyone — that is a statement about the domain, not about this identity.", + // Deliberately does not accuse the claimant, and deliberately does not + // suggest retrying. A misconfigured zone and active interference look the + // same from here, and the safe reading of both is the same: believe + // nothing this domain says until it is repaired. + "chain-failed": + "This domain's DNS records failed cryptographic validation. That is a broken zone or interference with the answer — either way, nothing this domain currently says about anyone can be trusted. This is not a problem with your connection and not a problem with this identity.", + replayed: + "This domain served a record older than one already seen for it, carried by a proof that has aged. That is what a replayed, superseded record looks like. It may be a stale cache on the path rather than an attack, but the record cannot be accepted either way.", + // Not a failure. Saying "check your clock" first is deliberate: the reader + // can act on that, and it is the overwhelmingly likelier cause. + deferred: + "This domain's records are dated further ahead than this device's clock allows. Usually that means the clock here is behind. The records are not rejected — they become usable once the clock catches up.", unsynced: "This domain designates a document this device has not synced, so the claim cannot be checked yet.", invalid: "Not a valid DNS name.", @@ -31,34 +100,82 @@ const STATUS_TONE: Record = { verified: "kh-text-primary kh-border-primary", pending: "kh-text-muted-foreground kh-border-border", mismatch: "kh-text-destructive kh-border-destructive", - unreachable: "kh-text-muted-foreground kh-border-border", + // Contested is not destructive: the zone is broken, the claimant is not + // accused. Tone it as a warning rather than a refusal. + contested: "kh-text-muted-foreground kh-border-border", + offline: "kh-text-muted-foreground kh-border-border", + malformed: "kh-text-destructive kh-border-destructive", + "chain-failed": "kh-text-destructive kh-border-destructive", + replayed: "kh-text-destructive kh-border-destructive", + // Deferred is a wait, not a warning. Neutral tone. + deferred: "kh-text-muted-foreground kh-border-border", + "no-claim": "kh-text-muted-foreground kh-border-border", unsynced: "kh-text-muted-foreground kh-border-border", invalid: "kh-text-destructive kh-border-destructive", }; +const FRESHNESS_NOTE: Record< + NonNullable, + string +> = { + fresh: "", + stale: + " The proof has lapsed — it was valid once and has not been refreshed. " + + "That is ordinary offline behaviour, not evidence of forgery.", + deferred: + " The proof's validity window has not opened yet, which usually means " + + "this device's clock is ahead. Neither confirmed nor refuted.", +}; + /** * A claimed DNS name, such as `@expede.wtf`, with its verification state. * * Without a status the claim renders as exactly that: a claim, visually no * stronger than a self-asserted display name. + * + * `freshness` grades the DNSSEC chain window and is **orthogonal** to the + * status: it qualifies whatever the verdict was rather than replacing it. A + * `stale` chain is a risk signal and never a forgery signal, so the binding + * still shows and the badge stays passive — no blocking, no interruption. */ export function DnsNameBadge({ dnsName, status, + freshness, + lapsedSeconds, className = "", }: DnsNameBadgeProps) { const tone = status ? STATUS_TONE[status] : "kh-text-muted-foreground kh-border-border"; + // Only an aged or not-yet-open proof is worth marking. `fresh` is the + // unremarkable case and adding a glyph for it would train the eye to look + // for one, making its absence the signal instead. + const aged = freshness === "stale" || freshness === "deferred"; + return ( @{dnsName} {status && } - {status && ({status})} + {aged && } + {status && ( + + ({status} + {aged ? `, ${freshness} proof` : ""}) + + )} ); } diff --git a/src/directory/types.ts b/src/directory/types.ts index 63c02f8..f8d4662 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -8,43 +8,137 @@ export type DirectoryEntryKind = "individual" | "group"; * Where a DNS name claim stands with a verifying directory. * * - `pending`: the claim is being resolved. - * - `verified`: a DNSSEC-validated `_onomancy` TXT record designates this id. + * - `verified`: a DNSSEC-validated `_onomancy` TXT record designates a + * document this id administers. **One-directional — see below.** * - `mismatch`: the record designates a different id. - * - `unreachable`: the binding could not be resolved (offline, no record, or - * an invalid chain), which proves nothing either way. + * - `contested`: the zone publishes two records of equal precedence naming + * different documents. It has failed to say who it designates — which is + * not the same as saying it is not this id. + * - `offline`: the DNS layer could not be reached at all. + * - `malformed`: the claim is a syntactically invalid hostname, so no query + * was ever possible. The remedy is in the claim, not the network. + * - `no-claim`: DNS answered and the domain publishes no usable `v=ONO0` + * record. Includes records that resolve but fail strict parsing. **Not a + * security signal** — there was nothing to prove, and nothing failed. + * - `chain-failed`: records arrived and failed DNSSEC validation. **This is + * the security signal.** A misconfigured zone and active interference are + * indistinguishable from here, and neither is the claimant's doing, so it + * accuses nobody — but it must never be rendered as an absence or as + * something retrying will fix. + * - `replayed`: a stale chain carried a serial no higher than one already + * accepted for this name. The zone — or something on the path — served a + * record known to be superseded. **A security signal.** + * - `deferred`: every record the zone published is dated beyond the clock + * skew bound. Not an absence and not a refusal: such records *ripen*. The + * usual cause is this device's clock running behind. * - `unsynced`: the domain designates a document this device has not synced, - * so membership cannot be checked yet. Also proves nothing either way. + * so membership cannot be checked yet. Proves nothing either way. * - `invalid`: the claim is not a DNS name at all. * + * ### Why `offline`, `malformed` and `no-claim` are three values + * + * They were one value, `unreachable`, and that value told every user their + * network was at fault. A typo and a domain that simply makes no claim both + * rendered as *"could not reach"*, and the remedy a reader infers from that + * — retry — helps neither. For a typo the fix is in the input box. + * + * The collapse was not a decision anybody made. A directory's vocabulary is + * *who is this*, so *why could I not tell you* had nowhere to live, and the + * natural shape discarded it. Splitting the value is what gives the reason + * somewhere to go. + * * ## Rules for anyone producing a status * - * This library renders these six values; it does not require that it be the - * thing that computes them. Applications that verify claims themselves must + * This library renders these twelve values; it does not require that it be + * the thing that computes them. Applications that verify claims themselves must * follow the same rules, because the badge means the same thing to a reader * whichever code produced it. * * 1. **`mismatch` requires a record that designates somebody.** A hostname * that resolves and DNSSEC-validates but whose records all fail strict * `v=ONO0` parsing proves nothing about any identity, so it is - * `unreachable`. This is not hypothetical: a live record was once a + * `no-claim`. This is not hypothetical: a live record was once a * hex-encoded `g=` field followed by a truncated `p=`, and every parse * rejected. Reporting that as `mismatch` would accuse the claimant on * the strength of somebody's typo. - * 2. **`unreachable` and `unsynced` are the absence of an answer**, never a - * weak `mismatch`. Do not collapse them into it, and do not collapse - * them into each other: the first means the DNS layer said nothing, the - * second that it spoke and the local device cannot yet check the reply. + * 2. **The non-answers are never a weak `mismatch`.** `offline`, + * `malformed`, `no-claim`, `contested` and `unsynced` all mean the + * question was not answered. Do not collapse them into `mismatch`, and + * do not collapse them into each other — they carry different remedies, + * which is the entire reason they are separate values. * 3. **`verified` requires positive evidence**, never the absence of * contrary evidence. - * 4. **Do not invent a seventh value.** A status outside this set has no + * 4. **`contested` is not `mismatch`.** A zone naming two documents at equal + * precedence has contradicted itself. Preferring either would manufacture + * a verdict the zone does not support. + * 5. **`no-claim` and `chain-failed` are opposites, not neighbours.** The + * first means the domain said nothing; the second that it said something + * which failed to verify. Collapsing them buries the only case here with + * a security reading inside the most ordinary one. + * 6. **A serial ratchet needs its skew bound.** `replayed` is only safe to + * produce alongside `deferred`. A verifier that remembers the highest + * serial but does not set aside future-dated ones can be jammed by a + * single forged record at a value nothing honest will exceed, and every + * genuine record thereafter reads as a replay. Shipping the memory + * without the bound is worse than shipping neither. + * 7. **Do not invent a thirteenth value.** A status outside this set has no * rendering and no agreed meaning. * * The errors this design tolerates run one way: it will not wrongly verify, * and it will sometimes fail to verify someone legitimate. Preserve that * asymmetry — it is what makes a badge worth trusting. + * + * ## What `verified` does NOT mean + * + * It is **not** the onomancy spec's *verified binding*, and the difference is + * worth stating because the words are close enough to be read as the same + * thing. + * + * The spec's binding runs through a certificate: the domain designates the + * document whose id appears *in the certificate*, and a key delegated by + * that document *signed* that certificate. This library consults no + * certificate. It checks that DNS designates a document, and that the + * identity is a delegated admin of it. + * + * Two consequences, and neither is merely "weaker evidence": + * + * 1. **It is not transferable.** A certificate is self-authenticating: anyone + * can check it against their own trust anchors, from bytes that arrived + * anywhere. This verdict is *local* — it needs the document replicated + * and keyhive state present, so a third party cannot be shown why the + * badge was earned. + * 2. **The document is not a participant.** A domain may unilaterally name + * any document id, and that document's admins cannot decline. Under the + * spec the document *speaks*, and refusing to sign is how it declines. + * Here, the only thing preventing a badge is the identity not claiming + * the name in the first place. + * + * What consent there is comes from the claim, not from an artifact: the + * badge renders only for entries that claimed a `dnsName`. So the identity + * asserts the domain and DNS corroborates — mutual in a weak sense, and not + * in the sense the spec means. + * + * **When certificates can be minted, do not overload this value.** There + * will then be two verifiable claims of different strength, and one glyph + * cannot carry both. The certificate-backed verdict wants its own status, + * decided before it exists rather than after. + * + * Raised by keyhive-todo-app-demo, from a human asking why a badge + * check-marks when the document has no reverse binding. */ export type DnsNameStatus = - "pending" | "verified" | "mismatch" | "unreachable" | "unsynced" | "invalid"; + | "pending" + | "verified" + | "mismatch" + | "contested" + | "offline" + | "malformed" + | "no-claim" + | "chain-failed" + | "replayed" + | "deferred" + | "unsynced" + | "invalid"; /** Display information for one keyhive identity. */ export interface DirectoryEntry { @@ -76,6 +170,34 @@ export interface DirectoryEntry { * Producers must follow the rules on {@link DnsNameStatus}. */ dnsNameStatus?: DnsNameStatus; + /** + * How current the DNSSEC chain was when the claim was checked — `fresh`, + * `stale`, or `deferred`. A decoration like `dnsNameStatus`, set by the + * same verifier and never stored. + * + * **Orthogonal to the status, not a member of it.** "Verified, checked + * just now" and "verified, but the proof lapsed a week ago" are the same + * verdict at two confidences. Absent when no chain was obtained, which is + * not the same as failing the axis. + * + * `stale` is a **risk signal, never a forgery signal** — a lapsed window + * is what offline operation looks like. Render it as a passive qualifier + * that still shows the binding; do not gate access on it and do not + * interrupt for it. `deferred` means the window has not opened, nearly + * always client clock skew: neither pass nor fail. + */ + dnsNameFreshness?: "fresh" | "stale" | "deferred"; + /** + * How far the chain had lapsed when checked, in seconds. Present only + * beside a `stale` freshness, and only when the runtime supplied the + * window it graded against. + * + * A proof that aged out an hour ago and one that aged out eight months + * ago are both `stale`, and they do not warrant the same reaction. Like + * the other `dnsName*` fields this is a decoration: computed per render, + * never stored, stripped on publish. + */ + dnsNameLapsedSeconds?: number; } export interface NameDirectory { diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index f343167..b21877a 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -40,6 +40,7 @@ export type { export { clearVerificationCache, + clearVerificationVerdicts, createOnomancyDirectory, createVerificationCache, } from "./verified-directory.js"; @@ -49,3 +50,4 @@ export type { } from "./verified-directory.js"; export { useOnomancyDirectory } from "./useOnomancyDirectory.js"; +export type { UseOnomancyDirectoryOptions } from "./useOnomancyDirectory.js"; diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 4290f25..bd5b2e2 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -42,12 +42,64 @@ export interface OnomancyModule { export interface OnomancyRuntimeOptions { /** DNS-over-HTTPS endpoint. The Wasm module's default when omitted. */ dohUrl?: string; + /** + * Milliseconds since the epoch. Defaults to `Date.now`. + * + * Injectable because the serial skew bound is a decision about *now*, and + * a test that cannot name the instant can only assert the behaviour it + * happens to observe. Upstream made the same parameter available on chain + * grading for the same reason, and it was the difference between a + * deterministic test and one that agreed with whatever it found. + */ + now?: () => number; } /** * A DNSSEC-verified binding: the root document ids a hostname's * `_onomancy` TXT records designate. */ +/** + * How current the DNSSEC chain was when it was graded. + * + * - `fresh` — the chain's signature window covers the moment it was checked. + * - `stale` — once-valid, window lapsed. **A risk signal, never a forgery + * signal**: a lapsed window is what offline operation looks like, so this + * must warn and must not gate access. The binding still shows. + * - `deferred` — the window has not opened yet, nearly always client clock + * skew. Neither pass nor fail: it means "cannot judge", and it preempts + * the grade rather than sitting beside it. + * - `undefined` — no chain was obtained, so the axis does not exist. Not the + * same as failing it. + * + * Orthogonal to `DnsNameStatus`, not a member of it. "Verified, checked just + * now" and "verified, but the proof lapsed a week ago" are the same verdict + * at two confidences; collapsing them would destroy the distinction between + * *could not check* and *checked, and it has aged*. + */ +export type ChainFreshness = "fresh" | "stale" | "deferred"; + +/** + * RFC 1035's limit on a fully-qualified domain name, plus one for the `@` + * sigil. A DNS name *claim* carries no path, so nothing legitimate is longer. + */ +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; + export interface HostnameBinding { hostname: string; /** @@ -56,6 +108,56 @@ export interface HostnameBinding { * a migration's dual-publish window. */ ids: string[]; + /** + * The serial of the winning record — the highest `n=` among those not set + * aside as future-dated. + * + * Surfaced because it outlives the query. A verifier is required to + * remember the highest serial it has accepted per name, so that a + * stale-chain record bearing a *lower* serial can be recognised as a + * replay of something already superseded. That comparison cannot happen + * inside one resolution, because the attack is one record at a time + * across two queries — never two records in one answer. + */ + serial?: bigint; + /** + * How many records were set aside for reading too far in the future. + * + * Not an error and not a rejection: such a record *ripens*, and the spec + * asks that it be deferred and retried rather than refused. Reported so a + * caller can distinguish "this domain publishes nothing" from "everything + * this domain publishes is dated ahead of my clock", which have opposite + * remedies — the second is usually the reader's own clock being behind. + */ + deferredSerials?: number; + /** + * The chain's grade at the moment it was checked, when the runtime reported + * one. See `lapsedSeconds` for the magnitude behind a `stale` grade. + */ + freshness?: ChainFreshness; + /** + * How far the chain's validity window had lapsed when it was checked, in + * seconds. Present only when the runtime returned both the window and the + * clock reading it graded against, and only when that grade was `stale` — + * a fresh chain has not lapsed and a deferred one has not begun. + * + * This is the difference between "the proof aged out an hour ago", which + * is ordinary for a device that has been asleep, and "the proof aged out + * eight months ago", which is worth a person's attention. Rendering both + * as the bare word *stale* throws that distinction away. + */ + lapsedSeconds?: number; + /** + * Absolute difference between the runtime's clock reading and this host's, + * in seconds, when both are known. + * + * Clock skew is *indistinguishable from genuine staleness* by grade alone: + * a device an hour fast sees valid chains as `deferred`, and one badly + * behind sees expired chains as `fresh`. A consumer that reports "the + * proof has not started yet" to a user whose clock is wrong has blamed + * the wrong party. + */ + clockSkewSeconds?: number; } export interface OnomancyRuntime { @@ -98,11 +200,87 @@ export function createOnomancyRuntime( hostname, options.dohUrl ?? null ); - return { hostname, ids: boundIdsOf(outcome) }; + 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; + } + if (freshness !== undefined) binding.freshness = freshness; + + // The window and the clock reading are the *inputs* to the grade, + // returned beside it so a caller can check the work rather than take + // the verdict on faith. + // + // Both are OPTIONAL and must stay so. This runtime takes an injected + // module, so the build in play is whatever the consumer installed, not + // whatever we tested against. Observed shapes, by artifact: + // + // sha256 2d8eab4f… { hostname, links, freshness, records, window, checkedAt } + // earlier 0.1.0 { hostname, links, freshness, records } + // + // The version string was `0.1.0` for both. A reader who checks against + // "0.1.0" and finds four keys is not looking at the same artifact, so + // the sha is the only identifier that means anything here. Verified + // against both: the four-key build yields freshness with the magnitude + // fields omitted, and nothing throws. + const chainWindow = validityWindowOf(outcome); + const checkedAt = finiteSeconds( + (outcome as { checkedAt?: unknown } | null)?.checkedAt + ); + + if (checkedAt !== undefined) { + binding.clockSkewSeconds = Math.abs( + checkedAt - Math.floor(Date.now() / 1000) + ); + } + + if (freshness === "stale" && chainWindow && checkedAt !== undefined) { + const lapsed = checkedAt - chainWindow.expiration; + // Only a positive lapse is meaningful. A non-positive one would mean + // the runtime graded stale against a window it still sits inside, + // which is a contradiction we report as "no magnitude" rather than + // as a negative age. + if (lapsed > 0) binding.lapsedSeconds = lapsed; + } + + return binding; }, normalizeDnsName: (raw) => { + // Guarded here rather than left to the module, because the module in + // play is whatever the CONSUMER installed. Builds before + // sha256 `308b6e30…` trap on a non-string with + // `RuntimeError: memory access out of bounds` — an unrecoverable Wasm + // fault, not a catchable error. Later builds reject cleanly with + // `a name must be a string`. + // + // A library cannot choose its consumer's build, so the guard stays + // even though upstream has since repaired it: for anyone still on an + // older artifact this is the difference between a caught error and a + // dead module. It costs one `typeof`. + if (typeof raw !== "string") { + throw new TypeError( + `A DNS name claim must be a string, got ${typeof raw}` + ); + } + const trimmed = raw.trim(); + + // Bounded before parsing. The upstream label walk is superlinear in + // the segment count, so an absurd input is a denial of service rather + // than a slow parse. A DNS name is capped at 253 octets by RFC 1035 + // and a claim carries no path, so anything longer is already invalid + // — rejecting it early costs nothing that a legitimate name needs. + if (trimmed.length > MAX_DNS_NAME_LENGTH) { + throw new Error( + `A DNS name cannot exceed ${MAX_DNS_NAME_LENGTH} characters` + ); + } + // The grammar requires a sigil; a claim is stored without one. const spelled = trimmed.startsWith("@") ? trimmed : `@${trimmed}`; @@ -122,36 +300,192 @@ export function createOnomancyRuntime( }; } -/** The hex-encoded `p=` document ids in a `resolveHostname` outcome. */ -function boundIdsOf(outcome: unknown): string[] { - if (typeof outcome !== "object" || outcome === null) return []; +/** A finite, non-negative epoch-seconds reading, or `undefined`. */ +function finiteSeconds(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + +/** + * The chain's signature validity window, when the runtime reported a + * coherent one. + * + * A window whose expiration precedes its inception is discarded rather than + * passed on: it cannot be used to compute an age, and a caller doing + * arithmetic on it would get a plausible-looking negative number instead of + * an absence. + */ +function validityWindowOf( + outcome: unknown +): { inception: number; expiration: number } | undefined { + if (typeof outcome !== "object" || outcome === null) return undefined; + const raw = (outcome as { window?: unknown }).window; + if (typeof raw !== "object" || raw === null) return undefined; + + const inception = finiteSeconds((raw as { inception?: unknown }).inception); + const expiration = finiteSeconds( + (raw as { expiration?: unknown }).expiration + ); + if (inception === undefined || expiration === undefined) return undefined; + + 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 []; - const ids: string[] = []; + 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 id = parseRecordDocId(record); - if (id !== undefined) ids.push(id); + const ono0 = parseRecord(record); + if (ono0 !== undefined) parsed.push(ono0); } - return ids; + + // 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. + * + * Unknown values are dropped rather than passed through: this feeds a + * security-adjacent display, and a grade nobody recognises should read as + * "no grade" rather than as a string rendered verbatim. + */ +function freshnessOf(outcome: unknown): ChainFreshness | undefined { + if (typeof outcome !== "object" || outcome === null) return undefined; + const grade = (outcome as { freshness?: unknown }).freshness; + return grade === "fresh" || grade === "stale" || grade === "deferred" + ? grade + : 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; } /** - * The hex-encoded root document id of one TXT record, or `undefined` when the - * record 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 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. */ -export function parseRecordDocId(record: string): string | undefined { - const match = record.match( - /^v=ONO0;k=ed25519;n=\d+;g=[A-Za-z0-9+/]+={0,2};p=([A-Za-z0-9+/]+={0,2})$/ - ); +export function parseRecord(record: string): Ono0Record | undefined { + const match = record.match(ONO0); if (!match) return undefined; - const bytes = base64ToBytes(match[1]); + + 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 bytesToHex(bytes); + + 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 { diff --git a/src/onomancy/useOnomancyDirectory.ts b/src/onomancy/useOnomancyDirectory.ts index c8156b4..811bb52 100644 --- a/src/onomancy/useOnomancyDirectory.ts +++ b/src/onomancy/useOnomancyDirectory.ts @@ -1,7 +1,8 @@ -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { NameDirectory } from "../directory/types.js"; import type { OnomancyRuntime } from "./runtime.js"; import { + clearVerificationVerdicts, createOnomancyDirectory, createVerificationCache, type OnomancyDirectoryOptions, @@ -21,18 +22,58 @@ import { * * Pass `options.cache` to share results more widely, or to hold the handle * you need for `clearVerificationCache`. + * + * ## Keeping verdicts current + * + * Pass `revalidate` — the counter from `useKeyhiveUpdates` is the intended + * source — and the designation verdicts are dropped whenever it changes, + * while DNS resolutions are kept. + * + * Without it, an entry whose designated document had not arrived at first + * read stays `unsynced` for the life of the cache, telling the user a + * document they are holding has not synced. The verdict is a claim about + * local keyhive state and goes stale for a local reason; nothing about DNS + * changed, so re-resolving would be wasted DoH traffic. + * + * `revalidate` is a **re-read trigger**, which is what `useKeyhiveUpdates` + * is safe as. It bumps on every `ingest-remote`, so it is a heartbeat rather + * than a version — do not key a timeout off it. */ +export interface UseOnomancyDirectoryOptions extends OnomancyDirectoryOptions { + /** + * Drop the designation verdicts whenever this changes, keeping DNS + * resolutions. Pass the counter from `useKeyhiveUpdates`. + * + * Hook-only: {@link createOnomancyDirectory} has no use for it, because + * outside React the caller decides when to re-check by calling + * `clearVerificationVerdicts` directly. + */ + revalidate?: unknown; +} + export function useOnomancyDirectory( base: NameDirectory, runtime: OnomancyRuntime, - options: OnomancyDirectoryOptions = {} + options: UseOnomancyDirectoryOptions = {} ): NameDirectory { - const { designation, notice, cache: provided } = options; + const { designation, notice, cache: provided, revalidate } = options; const held = useRef(null); if (held.current === null) held.current = createVerificationCache(); const cache = provided ?? held.current; + // Skip the first run: nothing has been verified yet, so there is nothing + // stale to drop, and clearing an empty cache would notify every subscriber + // for no reason. + const seen = useRef(false); + useEffect(() => { + if (!seen.current) { + seen.current = true; + return; + } + clearVerificationVerdicts(cache); + }, [cache, revalidate]); + return useMemo( () => createOnomancyDirectory(base, runtime, { designation, notice, cache }), diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index 6d82c79..c9ce746 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -8,20 +8,84 @@ import { type DesignationVerdict, type DnsDesignation, } from "./designation.js"; -import type { OnomancyRuntime } from "./runtime.js"; +import type { + ChainFreshness, + HostnameBinding, + OnomancyRuntime, +} from "./runtime.js"; type Resolution = | { phase: "pending" } - | { phase: "resolved"; ids: string[] } - | { phase: "unreachable" }; + | { + phase: "resolved"; + ids: string[]; + freshness?: ChainFreshness; + lapsedSeconds?: number; + } + // The reason the DNS layer gave no usable answer. These are separate + // phases rather than one `unreachable` because they carry different + // remedies: `offline` says retry, `malformed` says fix the claim, + // `no-claim` says the domain is not claiming anyone and no amount of + // waiting will change that, and `chain-failed` says something arrived and + // failed to verify — the only one of the four with a security reading. + | { phase: "offline" } + | { phase: "malformed" } + | { phase: "no-claim" } + | { phase: "chain-failed" } + | { phase: "replayed" } + | { phase: "deferred" }; type Verdict = { phase: "pending" } | { phase: "done"; verdict: DesignationVerdict }; +/** + * Two maps, not one, because they answer to different rules. + * + * `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. + * + * `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 + * nothing to say about it. It goes stale for an entirely local reason — the + * document arrives — and can be re-checked today, which is what + * {@link clearVerificationVerdicts} is for. + * + * Holding them in one map made the second look blocked on the first. + */ +/** + * One `subscribe` call. + * + * A wrapper rather than the bare function, because a `Set` of functions + * deduplicates by identity: two components passing the same stable callback + * — a `useCallback` with no dependencies, say — would register once, and the + * first unsubscribe would silently cancel the second's subscription. Each + * call gets its own object, so registrations count rather than collapse. + */ +interface Subscriber { + readonly notify: () => void; +} + interface CacheState { resolutions: Map; verdicts: Map; - listeners: Set<() => void>; + /** + * Highest serial accepted per hostname — the serial ratchet. + * + * Lives beside the caches rather than inside `resolutions` because it + * must **outlive** them. Resolutions are cleared on revalidation; the + * ratchet must not be, or the memory a replay defence depends on would be + * erased by the routine act of re-checking. + * + * Deliberately *not* a monotone maximum. A fresh chain may move it in + * either direction, because a ratchet that only rises can be jammed by a + * single forged high serial and could then never heal. + */ + ratchet: Map; + listeners: Set; } /** @@ -49,6 +113,7 @@ export function createVerificationCache(): VerificationCache { states.set(cache, { resolutions: new Map(), verdicts: new Map(), + ratchet: new Map(), listeners: new Set(), }); return cache; @@ -59,13 +124,42 @@ export function createVerificationCache(): VerificationCache { * * Subscribers are notified, since every claimed name reverts to `pending`. * Live subscriptions survive; only results are dropped. + * + * **The serial ratchet deliberately survives this.** It is not a cached + * answer but a memory of the highest serial ever accepted per name, and a + * replay defence that could be cleared by re-checking would defend nothing: + * an attacker who can prompt a revalidation could erase the evidence that + * their record is superseded. Nothing in this library clears it, which is + * why there is no `clearSerialRatchet` beside the other two. */ export function clearVerificationCache(cache: VerificationCache): void { const state = states.get(cache); if (!state) return; state.resolutions.clear(); state.verdicts.clear(); - for (const listener of state.listeners) listener(); + for (const listener of state.listeners) listener.notify(); +} + +/** + * Forget the designation verdicts, keeping DNS resolutions. + * + * A verdict is a claim about local keyhive state: whether the document a + * domain designates belongs to this identity. It has one common way of going + * stale — the document was not held when the claim was checked, and has since + * arrived. Nothing about DNS changed, so re-resolving would be wasted DoH + * traffic; only the local question needs asking again. + * + * Without this, an entry whose document arrives after the first read reads + * `unsynced` for the life of the cache: "this device has not synced the + * document", about a document the device is holding. Call it when keyhive + * membership may have changed — {@link useOnomancyDirectory}'s `revalidate` + * does exactly that. + */ +export function clearVerificationVerdicts(cache: VerificationCache): void { + const state = states.get(cache); + if (!state || state.verdicts.size === 0) return; + state.verdicts.clear(); + for (const listener of state.listeners) listener.notify(); } function stateOf(cache: VerificationCache): CacheState { @@ -120,16 +214,74 @@ export function createOnomancyDirectory( ): NameDirectory { const designation = options.designation ?? idEqualityDesignation; const cache = options.cache ?? createVerificationCache(); - const { resolutions, verdicts, listeners } = stateOf(cache); + const { resolutions, verdicts, ratchet, listeners } = stateOf(cache); + + /** + * Whether this answer is a replay of something already superseded. + * + * Only a **stale** chain can be a replay. A fresh chain is the zone + * speaking now, and is believed even when its serial is lower — that is + * the escape hatch that lets a poisoned ratchet heal, and it is also what + * makes legitimate re-registration of a domain possible. + * + * With no serial there is nothing to compare, so nothing is claimed: a + * runtime too old to report one gets the pre-ratchet behaviour rather than + * a fabricated verdict. + */ + function isReplay(hostname: string, binding: HostnameBinding): boolean { + if (binding.serial === undefined) return false; + if (binding.freshness === "fresh") return false; + + const seen = ratchet.get(hostname); + return seen !== undefined && binding.serial <= seen; + } + + /** + * Move the ratchet to this answer's serial. + * + * A fresh chain sets it in **either direction**; anything else may only + * raise it. The downward move is deliberate and is the whole reason this + * is not `Math.max`: without it, one forged far-future serial would lock + * the name permanently, and the defence would become the attack. + */ + function admitToRatchet(hostname: string, binding: HostnameBinding): void { + if (binding.serial === undefined) return; + + const seen = ratchet.get(hostname); + if ( + seen === undefined || + binding.freshness === "fresh" || + binding.serial > seen + ) { + ratchet.set(hostname, binding.serial); + } + } const notify = () => { - for (const listener of listeners) listener(); + // Snapshot: a listener may unsubscribe while being notified, and + // mutating the set mid-iteration would skip whoever follows it. + for (const listener of [...listeners]) listener.notify(); }; function resolutionFor(hostname: string): Resolution { const existing = resolutions.get(hostname); if (existing) return existing; + // Decided HERE, from the claim itself, rather than by reading the error + // that a query would produce. A syntactically impossible hostname cannot + // be looked up, so there is nothing to wait for and no network to blame + // — and we can say so without a round trip. + // + // Deliberately not classified from message text. Two of the strings this + // would have matched on changed upstream within one afternoon, so a + // classifier built on them would have broken twice in a day. Anything we + // cannot determine structurally stays in the conservative bucket. + if (!isSyntacticallyResolvable(hostname)) { + const malformed: Resolution = { phase: "malformed" }; + resolutions.set(hostname, malformed); + return malformed; + } + const pending: Resolution = { phase: "pending" }; resolutions.set(hostname, pending); runtime.resolveBoundIds(hostname).then( @@ -137,16 +289,40 @@ export function createOnomancyDirectory( // No parseable records proves nothing about any identity, the same // as not resolving at all. A mismatch requires a record that // designates someone. - resolutions.set( - hostname, - binding.ids.length === 0 - ? { phase: "unreachable" } - : { phase: "resolved", ids: binding.ids.map(bareId) } - ); + if (binding.ids.length === 0) { + // Everything the zone published was dated past the skew bound. + // Not an absence and not a refusal — those records ripen, and + // the usual cause is this device's clock being behind. + resolutions.set( + hostname, + binding.deferredSerials + ? { phase: "deferred" } + : { phase: "no-claim" } + ); + } else if (isReplay(hostname, binding)) { + // 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); + + const resolved: Resolution = { + phase: "resolved", + ids: binding.ids.map(bareId), + }; + if (binding.freshness !== undefined) { + resolved.freshness = binding.freshness; + } + if (binding.lapsedSeconds !== undefined) { + resolved.lapsedSeconds = binding.lapsedSeconds; + } + resolutions.set(hostname, resolved); + } notify(); }, - () => { - resolutions.set(hostname, { phase: "unreachable" }); + (error: unknown) => { + resolutions.set(hostname, { phase: phaseForRejection(error) }); notify(); } ); @@ -162,15 +338,52 @@ export function createOnomancyDirectory( const existing = verdicts.get(key); if (existing) return existing; + // A contested binding is refused before any designation sees it. + // + // Record selection has already taken the highest serial and collapsed + // agreeing duplicates, so more than one id here means two records claim + // to be equally current and name *different* documents. The zone + // contradicts itself, and there is no ground for preferring either. + // + // This must be refused centrally rather than left to each designation, + // because the natural implementation is membership — `boundIds.includes` + // — which accepts a contested set whenever the entry is any one of its + // members. That turns "the zone disagrees with itself" into "verified", + // and it is reachable by anyone who can get a same-serial record into + // the RRset beside the real one. + // + // The verdict is `unknown`, not `excludes`: the zone has failed to say + // who it designates, which is not the same as saying it is not this + // entry. + if (ids.length > 1) { + const contested: Verdict = { phase: "done", verdict: "unknown" }; + verdicts.set(key, contested); + return contested; + } + + // `DnsDesignation` may return a verdict or a promise, so it may also + // throw synchronously. `Promise.resolve(f())` evaluates `f()` first and + // catches only asynchronous failure, so a synchronous throw would escape + // through decorate() and lookup() into render — and, having never settled + // the entry, leave it pending forever afterwards. + let outcome: DesignationVerdict | Promise; + try { + outcome = designation(entry, ids, hostname); + } catch { + // A designation that throws has answered nothing. + const answered: Verdict = { phase: "done", verdict: "unknown" }; + verdicts.set(key, answered); + return answered; + } + const pending: Verdict = { phase: "pending" }; verdicts.set(key, pending); - Promise.resolve(designation(entry, ids, hostname)).then( + Promise.resolve(outcome).then( (verdict) => { verdicts.set(key, { phase: "done", verdict }); notify(); }, () => { - // A designation that throws has answered nothing. verdicts.set(key, { phase: "done", verdict: "unknown" }); notify(); } @@ -192,8 +405,29 @@ export function createOnomancyDirectory( if (resolution.phase === "pending") { return { ...entry, dnsNameStatus: "pending" }; } - if (resolution.phase === "unreachable") { - return { ...entry, dnsNameStatus: "unreachable" }; + if ( + resolution.phase === "offline" || + resolution.phase === "malformed" || + resolution.phase === "no-claim" || + resolution.phase === "chain-failed" || + resolution.phase === "replayed" || + resolution.phase === "deferred" + ) { + return { ...entry, dnsNameStatus: resolution.phase }; + } + + // A contested zone is decided structurally, before any designation is + // consulted. Record selection has already taken the top serial and + // collapsed agreeing duplicates, so more than one id means two records + // of equal precedence name different documents. + // + // This cannot go through `verdictFor`, because every verdict it can + // return is an answer about *this entry* — and the zone has not made + // one. Routing it there yielded `unknown`, which rendered as `unsynced`: + // "wait for a document to arrive", when nothing is arriving and the + // remedy belongs to whoever controls the DNS records. + if (resolution.ids.length > 1) { + return { ...entry, dnsNameStatus: "contested" }; } const verdict = verdictFor(entry, hostname, resolution.ids); @@ -205,7 +439,23 @@ export function createOnomancyDirectory( : verdict.verdict === "excludes" ? "mismatch" : "unsynced"; - return { ...entry, dnsNameStatus: status }; + + // Freshness rides alongside the status rather than inside it. It grades + // the chain window only and says nothing about acceptance, so a stale + // `verified` is still verified — by evidence that has aged. + if (resolution.freshness === undefined) { + return { ...entry, dnsNameStatus: status }; + } + + const decorated: DirectoryEntry = { + ...entry, + dnsNameStatus: status, + dnsNameFreshness: resolution.freshness, + }; + if (resolution.lapsedSeconds !== undefined) { + decorated.dnsNameLapsedSeconds = resolution.lapsedSeconds; + } + return decorated; } const directory: NameDirectory = { @@ -227,10 +477,11 @@ export function createOnomancyDirectory( subscribe(listener) { // Into the cache's set, not this directory's: a check started before a // rebuild must still reach whoever is listening when it lands. - listeners.add(listener); + const subscriber: Subscriber = { notify: listener }; + listeners.add(subscriber); const unsubscribe = base.subscribe?.(listener); return () => { - listeners.delete(listener); + listeners.delete(subscriber); unsubscribe?.(); }; }, @@ -251,3 +502,87 @@ export function createOnomancyDirectory( 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. + * + * Read off a property rather than matched from message text. Two of the + * strings this would otherwise have keyed on changed upstream inside one + * afternoon, so a text classifier would have broken twice in a day. `reason` + * is a contract; the message is prose that happens to be stable. + * + * The mapping is by **remedy**, because that is the only thing the badge can + * act on: + * + * | reason | status | what the reader should do | + * |--------------------|----------------|---------------------------| + * | `transport` | `offline` | retry — it may work | + * | `no-binding` | `no-claim` | nothing; there is nothing to prove | + * | `invalid-hostname` | `malformed` | fix the claim | + * | `chain-rejected` | `chain-failed` | trust nothing from this zone | + * + * `no-binding` and `chain-rejected` are the pair that must not merge. The + * first means DNS answered and there was nothing to prove; the second that + * records arrived and failed. Only the second is a security signal, and + * presenting it as an absence would hide it inside the most ordinary + * outcome there is. + * + * An unrecognised or absent `reason` falls back to `offline`, which is the + * conservative reading: it asserts only that the lookup did not complete. + * Consumers on a runtime older than the typed-reason build land here, and + * that is correct rather than degraded — the information genuinely is not + * available from them. + */ +function phaseForRejection( + error: unknown +): "offline" | "malformed" | "no-claim" | "chain-failed" { + if (typeof error !== "object" || error === null) return "offline"; + + switch ((error as { reason?: unknown }).reason) { + case "invalid-hostname": + return "malformed"; + case "no-binding": + return "no-claim"; + case "chain-rejected": + return "chain-failed"; + case "transport": + return "offline"; + default: + return "offline"; + } +} + +/** + * Whether a hostname could be looked up at all. + * + * Deliberately *permissive*: this decides only whether a query is possible, + * not whether the name is good. Anything that might resolve is sent to the + * resolver, which is the authority on the rest. The asymmetry is the point — + * a false `malformed` accuses a claimant of a typo they did not make, while a + * false pass merely costs one query that fails honestly. + * + * The rules here are the ones that make a lookup *impossible* rather than + * merely unlikely: + * + * - fewer than two labels — dotless domains do not exist + * - an empty label — a leading, trailing or doubled dot + * - an all-digit final label — that is an IP literal, not a name + * - a label over 63 octets, or a name over 253 — RFC 1035 + * - characters outside the A-label set + */ +function isSyntacticallyResolvable(hostname: string): boolean { + if (hostname.length === 0 || hostname.length > 253) return false; + + const labels = hostname.split("."); + if (labels.length < 2) return false; + + for (const label of labels) { + if (label.length === 0 || label.length > 63) return false; + if (!/^[A-Za-z0-9-]+$/.test(label)) return false; + if (label.startsWith("-") || label.endsWith("-")) return false; + } + + const tld = labels[labels.length - 1]!; + return !/^[0-9]+$/.test(tld); +} From c81c994cc110a4d77e1c6a106c72ee1405c956ab Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:52 -0700 Subject: [PATCH 05/16] Remove the solo short-circuit from keyhive designation The branch graded a p= equal to the identity's own key as designates. The spec requires p= to name a document (specs/anchoring/dns-anchor.md:72) and is explicit that the key alone is not an identity, since the same key bytes may be delegated in more than one document (:144). The "solo publisher" allowance that motivated the branch is about g=, not p=. A bare-key p= now reaches documentDelegatesTo, finds no document, and grades unknown: not refuted, not proven. idEqualityDesignation survives as an explicitly-labelled stub for tests. --- src/onomancy/designation.ts | 40 +++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/onomancy/designation.ts b/src/onomancy/designation.ts index d676096..5b5c311 100644 --- a/src/onomancy/designation.ts +++ b/src/onomancy/designation.ts @@ -34,8 +34,28 @@ export type DnsDesignation = ( ) => Promise | DesignationVerdict; /** - * The solo case: the bound id is the identity itself. This is the default, - * and the right check when accounts anchor domains directly to their key. + * Plain id equality: the bound id is the identity itself. + * + * **Not a conformant production check**, and deliberately not the default for + * keyhive documents. The onomancy spec is unambiguous that `p=` names a + * *document*: + * + * > `p` MUST be the base64 encoding of the 32-byte root document ID (an + * > ed25519 verifying key) — *specs/anchoring/dns-anchor.md:72* + * + * and that a bare key is not an identity at all: + * + * > the key alone is not an identity, since the same key bytes may be + * > delegated in more than one document — *dns-anchor.md:144* + * + * So a `p=` naming an individual is a configuration the spec does not + * define, and this function grades it `designates` anyway. That is fine for + * a **stub or a test**, where the point is a deterministic outcome with no + * keyhive documents behind it, and wrong for anything a person reads as + * proof: there is no document, so there can be no certificate, so no third + * party can be shown why the verdict was reached. + * + * Use {@link createKeyhiveDesignation} for real bindings. * * A bound id that is somebody else's `excludes`, because a record naming a * different identity is a positive statement about who the domain means. @@ -54,8 +74,18 @@ export type KeyhiveDesignationOptions = DocumentDelegationOptions; * A composition, not an implementation. The DNS half is here; the keyhive * half is {@link documentDelegatesTo}, which knows nothing about domains. * - * The solo case is included: a bound id that is the identity itself - * designates directly, so anchors of either shape verify. + * **No solo short-circuit.** An earlier version accepted a `p=` that named + * the identity's own key directly, on the reading that a "solo publisher" + * may anchor to their key. That conflated two different uses of the word: + * the spec's solo allowance is about **`g=`**, permitting a solo publisher's + * *generation key* to be their own admin key (`doc → admin`, the chain + * trivially passing through) — it is not permission for `p=` to skip the + * document. + * + * A `p=` naming an individual therefore reaches `documentDelegatesTo`, finds + * no document under that id, and grades `unknown`: not refuted, not proven. + * Which is what it is — a bare key can carry no certificate, so nothing about + * it is checkable by anyone but the verifier that computed it. * * Inherits {@link documentDelegatesTo}'s limit — an identity holding admin * through a nested group reads `unknown`, never `excludes`. @@ -67,8 +97,6 @@ export function createKeyhiveDesignation( ): DnsDesignation { return async (entry, boundIds) => { const identityId = bareId(entry.id); - // The domain may anchor the key directly rather than a document. - if (boundIds.map(bareId).includes(identityId)) return "designates"; const verdict = await documentDelegatesTo( runtime, From 04946c60e92694cdcb4e44c74f1013cd2adcce4a Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:45:52 -0700 Subject: [PATCH 06/16] Refuse publishing directory entries under the reserved onomancy key lookup and list already filter the reserved key, so a write under it succeeded and then became unreadable: it landed in the document and every read path hid it, while each retry wrote again into the region onomancy uses for protocol data. The spec reserves that region deliberately - whoever can write the document holding a certificate can remove or replace it, a naming-layer capability otherwise reserved to admin-delegated keys - and a profile write must not be a route to it. Throwing rather than dropping: a silent no-op is indistinguishable from a write that worked when the read paths hide it either way. --- src/directory/automerge-directory.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index 981aabc..55ed86a 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -80,6 +80,30 @@ export function createAutomergeDocDirectory( if (change) { directory.publish = (entry: DirectoryEntry) => { + // Refused loudly, because `lookup` and `list` already filter this key. + // + // Without the guard a write under the reserved id **succeeds and then + // becomes unreadable**: it lands in the document, and every read path + // in this module hides it. A caller sees no entry, retries, and each + // attempt writes again into the region onomancy uses for protocol + // data. + // + // The spec reserves that region deliberately — the certificate lives + // in the bound document, and *"whoever can write the document holding + // it can remove or replace it — a naming-layer capability that [Who + // Signs] otherwise reserves to admin-delegated keys"* + // (specs/anchoring/dns-anchor.md:225). A profile write must not be a + // route to that capability. + // + // Throwing rather than dropping: a caller publishing under this id has + // made a mistake, and a silent no-op is indistinguishable from a write + // that worked, given the read paths hide it either way. + if (entry.id === RESERVED_ONOMANCY_KEY) { + throw new Error( + `"${RESERVED_ONOMANCY_KEY}" is reserved for onomancy protocol data and cannot be used as a directory entry id.` + ); + } + change((d) => { const existing = d[entry.id]; if (!existing) { From 4d922c7ec0c783c5a91299411f43f47fbc567365 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:46:02 -0700 Subject: [PATCH 07/16] Walk transitive membership in documentDelegatesTo The function read document.members() - direct delegations only - so absence proved nothing and every non-member graded unknown, which rendered as "not synced yet": the badge told people to wait for a document that had already arrived. The doc comment justifying this claimed no API exposes transitive delegations with capabilities, which stopped being true when docMemberCapabilities landed. With a transitive walk, a held document that reaches nobody by any path is positive evidence of non-membership (insufficient -> mismatch), and unknown goes back to meaning the document is not here to ask. This also fixes the inverse case: an admin through a group was unknown too, so a legitimate member saw "not synced" about a document they could read. --- src/access/delegation.ts | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/access/delegation.ts b/src/access/delegation.ts index 9f7cb9e..88bbf5f 100644 --- a/src/access/delegation.ts +++ b/src/access/delegation.ts @@ -35,13 +35,21 @@ export interface DocumentDelegationOptions { * question they are asking — including DNS name verification, where the * documents come from a domain's `_onomancy` record. * - * Only each document's own delegations are consulted. An identity holding - * access through a nested group is `unknown`, not `insufficient`, because - * keyhive's `members()` reports a document's own delegations and those do - * not change when a group that already has access gains a member. Resolving - * that needs transitive delegations *with* their capabilities, which no - * current API exposes: `cgkaMembers()` returns bare `Identifier`s, and - * `Identifier` carries no access level at all. + * The walk is **transitive**: `docMemberCapabilities` expands nested groups + * and reports each reachable identity with the access its chain actually + * grants (the minimum along the chain, not the level of the last edge). + * + * That is what makes a negative answer meaningful. An earlier version read + * `document.members()` — direct delegations only — so absence from the list + * proved nothing, since access might route through a group it did not walk. + * Every such case had to grade `unknown`, and `unknown` renders as *"not + * synced yet"*: the badge told people to wait for a document that had + * already arrived. + * + * With a transitive walk, a held document that does not list the identity is + * **positive evidence of non-membership**, so it grades `insufficient` + * rather than `unknown`. `unknown` now means what it says: the document is + * not here to ask. * * @example * ```ts @@ -60,27 +68,25 @@ export async function documentDelegatesTo( const minimum = runtime.Access.fromString(options.minimumAccess ?? "admin"); let anyHeld = false; - let anyDirectMember = false; for (const documentId of documentIds) { - const document = await hive.keyhive.getDocument( - new runtime.DocumentId(hexToBytes(bareId(documentId))) - ); + const docId = new runtime.DocumentId(hexToBytes(bareId(documentId))); + const document = await hive.keyhive.getDocument(docId); if (!document) continue; anyHeld = true; - for (const capability of await document.members()) { + for (const capability of await hive.docMemberCapabilities(docId)) { const memberId = bytesToHex(capability.who.id.toBytes()); if (memberId !== wanted) continue; - anyDirectMember = true; if (capability.can.atLeast(minimum)) return "delegates"; } } - // Only a direct delegation below the minimum is insufficient. An unheld - // document proves nothing, and neither does absence from the direct - // members: access may route through a group this check does not walk. - return anyHeld && anyDirectMember ? "insufficient" : "unknown"; + // A held document that does not reach this identity by any path is + // evidence of non-membership, not an absence of evidence — which is only + // true because the walk above is transitive. An unheld document still + // proves nothing: it is not here to ask. + return anyHeld ? "insufficient" : "unknown"; } /** Hex ids without an `0x` prefix, lowercased, for comparison. */ From 2b43e01d53fb8af31a3d3117134dfb616307c426 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:46:02 -0700 Subject: [PATCH 08/16] Point at @inkandswitch/onomancy 0.2.0 from npm First registry version whose identifier names exactly one artifact; byte-identical to the local tree both apps verified against. --- apps/component-test-app/package.json | 2 +- pnpm-lock.yaml | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index 8534006..b939b4e 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", "@automerge/keyhive-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", - "@inkandswitch/onomancy": "link:../../../onomancy/onomancy_wasm", + "@inkandswitch/onomancy": "0.2.0", "@keyhive/keyhive": "0.1.0-alpha.8", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abf29f8..36f0673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,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: link:../../../onomancy/onomancy_wasm - version: link:../../../onomancy/onomancy_wasm + specifier: 0.2.0 + version: 0.2.0 '@keyhive/keyhive': specifier: 0.1.0-alpha.8 version: 0.1.0-alpha.8 @@ -477,6 +477,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inkandswitch/onomancy@0.2.0': + resolution: {integrity: sha512-keh5i80jwtoCK6+6w1vwvGpvskhlZW5XF2OyjxRLsvPFCFpqmPTtZ5c1h9SGReiZNoh3icGpdZ2wG0fuLNx8mQ==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1957,6 +1960,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inkandswitch/onomancy@0.2.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 From 9efd6bf4029adc8551f5edcc6b1745205dd74939 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 00:59:22 -0700 Subject: [PATCH 09/16] Trim comments to present-tense rules Comments narrated the edits that produced the code - old copy quoted, earlier versions described, session names and artifact shas cited. The tree now states current rules; the narrative lives in the decision log and the commit messages. Also fixes text that had drifted from the code: README and the onomancy entry point said six statuses where there are twelve, and an e2e assertion held the pre-split tooltip copy ("could not be resolved") that no status renders anymore. --- README.md | 2 +- apps/component-test-app/src/App.tsx | 35 +++++++----------- e2e/dns-names.spec.ts | 11 +++--- src/access/delegation.ts | 19 +++------- src/access/targets.ts | 17 +++------ src/components/primitives/DnsNameBadge.tsx | 23 ++++-------- src/directory/automerge-directory.ts | 25 ++++--------- src/directory/types.ts | 18 +++------- src/onomancy/designation.ts | 41 ++++++---------------- src/onomancy/index.ts | 2 +- src/onomancy/runtime.ts | 37 ++++++------------- src/onomancy/verified-directory.ts | 29 ++++++--------- 12 files changed, 78 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index bfe15fd..9e56e66 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ ed25519 verifying key, and the record is validated locally from the IANA root ### Where the pieces live This package keeps the _vocabulary_ and sheds the _mechanism_. The main entry -point knows what a claim is, what the six statuses mean, and how to render +point knows what a claim is, what the twelve statuses mean, and how to render them; it resolves nothing. Everything that performs DNS lives behind a separate import: diff --git a/apps/component-test-app/src/App.tsx b/apps/component-test-app/src/App.tsx index 5d3b38c..7d4576c 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -519,19 +519,12 @@ function NamesSection({ const [query, setQuery] = useState(""); const [outcome, setOutcome] = useState(null); - // The hostname this outcome came from, captured at submit rather than read - // from `query` at render. The input keeps taking keystrokes while the - // resolve is in flight, so reading it later would caption one result with - // another name — and this caption makes a security claim, so a mismatched - // hostname would be a lie rather than a cosmetic slip. - // - // This is the WEAKER of the two available fixes, and deliberately marked as - // such. keyhive-todo-app-demo derives both the caption and the document it - // captions from a single route object read in one render, so the two cannot - // disagree — the mismatch is unrepresentable. Here it is merely prevented, - // and prevention depends on the next reader noticing why this state exists - // rather than reaching for `query`, which nothing in the code stops them - // doing. Treat it as a guarded problem, not a solved one. + // The hostname this outcome came from, captured at submit — never read + // from `query` at render, which keeps taking keystrokes while the resolve + // is in flight and would caption one result with another name. The caption + // makes a security claim, so a mismatched hostname is a lie, not a slip. + // Capture only prevents the mismatch; deriving caption and document from + // one route object would make it unrepresentable. Guarded, not solved. const [resolvedHostname, setResolvedHostname] = useState(null); const [resolveError, setResolveError] = useState(null); const [resolving, setResolving] = useState(false); @@ -639,16 +632,12 @@ function NamesSection({

)} {outcome?.status === "resolved" && resolvedHostname !== null && ( - // Shown only for a resolved `@hostname` route. DNS got us to a - // document; it did not show that the document accepts the domain — - // that needs the onomancy certificate, and there is no JS API to - // obtain one. Certificates travel inside the bound document and - // arrive by replication, so the honest verb is *hold*, not *fetch*: - // there is no retrieval anywhere in the design to not-yet-do. - // - // Worded identically in keyhive-todo-app-demo. Two apps disagreeing - // about what the same unproven thing means is worse than either - // wording alone. + // Shown only for a resolved `@hostname` route. DNS reached a + // document; nothing proved the document accepts the domain — that + // needs the onomancy certificate. Certificates arrive by replication + // inside the bound document, so the verb is *hold*, not *fetch*. + // The wording is shared with keyhive-todo-app-demo; keep them in + // step if it changes.

Resolved through DNS. Nothing here proves this document accepts{" "} {resolvedHostname} — that check needs the onomancy diff --git a/e2e/dns-names.spec.ts b/e2e/dns-names.spec.ts index ee33b1a..6d08897 100644 --- a/e2e/dns-names.spec.ts +++ b/e2e/dns-names.spec.ts @@ -24,12 +24,9 @@ test.describe("DNS names verified through onomancy", () => { await claimDnsName(page, "@self.test"); const claimed = badge(page, "@self.test"); await expect(claimed).toBeVisible(); - // Asserts the CAVEAT, not the brand word. The tooltip previously read - // "DNSSEC-verified: this domain designates this identity", which was - // one-directional evidence phrased as mutual consent. Locking the - // disclaimer is what stops that regressing: a future edit may reword the - // positive half freely, and must not drop the half that says the - // document has not spoken. + // Assert the caveat, not the brand word: the positive half may be + // reworded freely; the half saying the document has not spoken must not + // be dropped. await expect(claimed).toHaveAttribute("title", /DNSSEC-valid/); await expect(claimed).toHaveAttribute( "title", @@ -59,7 +56,7 @@ test.describe("DNS names verified through onomancy", () => { await claimDnsName(page, "nowhere.test"); const claimed = badge(page, "@nowhere.test"); await expect(claimed).toBeVisible(); - await expect(claimed).toHaveAttribute("title", /could not be resolved/); + await expect(claimed).toHaveAttribute("title", /could not be reached/); }); test("a dotless name is rejected before it is stored", async ({ page }) => { diff --git a/src/access/delegation.ts b/src/access/delegation.ts index 88bbf5f..b1d923d 100644 --- a/src/access/delegation.ts +++ b/src/access/delegation.ts @@ -35,20 +35,11 @@ export interface DocumentDelegationOptions { * question they are asking — including DNS name verification, where the * documents come from a domain's `_onomancy` record. * - * The walk is **transitive**: `docMemberCapabilities` expands nested groups - * and reports each reachable identity with the access its chain actually - * grants (the minimum along the chain, not the level of the last edge). - * - * That is what makes a negative answer meaningful. An earlier version read - * `document.members()` — direct delegations only — so absence from the list - * proved nothing, since access might route through a group it did not walk. - * Every such case had to grade `unknown`, and `unknown` renders as *"not - * synced yet"*: the badge told people to wait for a document that had - * already arrived. - * - * With a transitive walk, a held document that does not list the identity is - * **positive evidence of non-membership**, so it grades `insufficient` - * rather than `unknown`. `unknown` now means what it says: the document is + * The walk is transitive: `docMemberCapabilities` expands nested groups and + * reports each reachable identity with the access its chain grants (the + * minimum along the chain, not the level of the last edge). Because the walk + * is complete, a held document that reaches nobody is positive evidence of + * non-membership (`insufficient`); `unknown` means only that the document is * not here to ask. * * @example diff --git a/src/access/targets.ts b/src/access/targets.ts index 3f359fb..2566d82 100644 --- a/src/access/targets.ts +++ b/src/access/targets.ts @@ -209,19 +209,10 @@ export function createDocumentTarget( }; }); - // Union, not either-or. - // - // Returning only `direct` whenever it is non-empty drops everyone whose - // access arrives through a group — and `direct` is *always* non-empty, - // because this identity is itself a direct member of any document it - // can see. So the transitive branch above was unreachable in practice. - // - // Measured consequence, not a hypothetical: a document's generated - // owner group holds Admin and is filtered out just above as machinery. - // A person added to that group therefore holds Admin transitively, and - // under the old either-or they appeared in no list this component could - // render — unshown, and unrevokable through a UI that revokes from - // the list it shows. + // Union, never either-or: a document's generated owner group holds + // Admin and is filtered out just above as machinery, so a person added + // to that group holds Admin transitively — and a member list that + // omits them is also a revocation UI that cannot revoke them. // // The group itself stays hidden and its members are shown: the group is // machinery, its members are people. That does mean a row can appear diff --git a/src/components/primitives/DnsNameBadge.tsx b/src/components/primitives/DnsNameBadge.tsx index 1322259..5498135 100644 --- a/src/components/primitives/DnsNameBadge.tsx +++ b/src/components/primitives/DnsNameBadge.tsx @@ -55,15 +55,9 @@ const STATUS_GLYPH: Record = { }; const STATUS_TITLE: Record = { - // States what was checked, which is one direction only. - // - // The old copy read "DNSSEC-verified: this domain designates this - // identity", which a reader takes as mutual. It is not: DNS names a - // document and this identity administers that document. The **document - // has never asserted the domain** — that is the onomancy certificate, and - // nothing here consults one. A domain may unilaterally name any document - // id, and its admins cannot decline; only their own non-claim keeps this - // badge from appearing. + // One direction only: DNS names a document this identity administers. + // The document has never asserted the domain — that is the certificate, + // and nothing here consults one — so the copy must not read as mutual. verified: "This domain's DNS records are DNSSEC-valid and designate a document that this identity administers. The document has not itself asserted this domain — that needs an onomancy certificate, which this check does not consult.", pending: "Checking this domain's DNS binding.", @@ -72,17 +66,14 @@ const STATUS_TITLE: Record = { "This domain publishes two conflicting records of equal precedence, naming different documents. It has not said who it designates, which is not the same as saying it is not this identity.", offline: "This domain's DNS binding could not be reached. Nothing is proven either way — try again when you are back online.", - // Names the remedy, and the remedy is not the network. The old copy for - // this case said "could not be resolved", which sent a person to check - // their connection over a typo they could see. + // Names the remedy, and the remedy is not the network. malformed: "That is not a valid hostname, so no lookup was possible. Check the spelling of the claim.", "no-claim": "This domain answered and publishes no usable onomancy record. It is not claiming anyone — that is a statement about the domain, not about this identity.", - // Deliberately does not accuse the claimant, and deliberately does not - // suggest retrying. A misconfigured zone and active interference look the - // same from here, and the safe reading of both is the same: believe - // nothing this domain says until it is repaired. + // Accuses nobody and suggests no retry: a misconfigured zone and active + // interference look the same from here, and the safe reading of both is + // to believe nothing this domain says until it is repaired. "chain-failed": "This domain's DNS records failed cryptographic validation. That is a broken zone or interference with the answer — either way, nothing this domain currently says about anyone can be trusted. This is not a problem with your connection and not a problem with this identity.", replayed: diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index 55ed86a..4ce7f0e 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -80,24 +80,13 @@ export function createAutomergeDocDirectory( if (change) { directory.publish = (entry: DirectoryEntry) => { - // Refused loudly, because `lookup` and `list` already filter this key. - // - // Without the guard a write under the reserved id **succeeds and then - // becomes unreadable**: it lands in the document, and every read path - // in this module hides it. A caller sees no entry, retries, and each - // attempt writes again into the region onomancy uses for protocol - // data. - // - // The spec reserves that region deliberately — the certificate lives - // in the bound document, and *"whoever can write the document holding - // it can remove or replace it — a naming-layer capability that [Who - // Signs] otherwise reserves to admin-delegated keys"* - // (specs/anchoring/dns-anchor.md:225). A profile write must not be a - // route to that capability. - // - // Throwing rather than dropping: a caller publishing under this id has - // made a mistake, and a silent no-op is indistinguishable from a write - // that worked, given the read paths hide it either way. + // 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) { throw new Error( `"${RESERVED_ONOMANCY_KEY}" is reserved for onomancy protocol data and cannot be used as a directory entry id.` diff --git a/src/directory/types.ts b/src/directory/types.ts index f8d4662..7a9ce48 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -35,17 +35,12 @@ export type DirectoryEntryKind = "individual" | "group"; * so membership cannot be checked yet. Proves nothing either way. * - `invalid`: the claim is not a DNS name at all. * - * ### Why `offline`, `malformed` and `no-claim` are three values + * ### Why the non-answers are separate values * - * They were one value, `unreachable`, and that value told every user their - * network was at fault. A typo and a domain that simply makes no claim both - * rendered as *"could not reach"*, and the remedy a reader infers from that - * — retry — helps neither. For a typo the fix is in the input box. - * - * The collapse was not a decision anybody made. A directory's vocabulary is - * *who is this*, so *why could I not tell you* had nowhere to live, and the - * natural shape discarded it. Splitting the value is what gives the reason - * somewhere to go. + * They carry different remedies — retry, fix the input, tell the domain + * owner, wait for a clock, trust nothing from this zone — and the remedy is + * the only thing a badge can act on. Collapsing them tells a user with a + * typo to check their network. * * ## Rules for anyone producing a status * @@ -122,9 +117,6 @@ export type DirectoryEntryKind = "individual" | "group"; * will then be two verifiable claims of different strength, and one glyph * cannot carry both. The certificate-backed verdict wants its own status, * decided before it exists rather than after. - * - * Raised by keyhive-todo-app-demo, from a human asking why a badge - * check-marks when the document has no reverse binding. */ export type DnsNameStatus = | "pending" diff --git a/src/onomancy/designation.ts b/src/onomancy/designation.ts index 5b5c311..2342702 100644 --- a/src/onomancy/designation.ts +++ b/src/onomancy/designation.ts @@ -36,24 +36,12 @@ export type DnsDesignation = ( /** * Plain id equality: the bound id is the identity itself. * - * **Not a conformant production check**, and deliberately not the default for - * keyhive documents. The onomancy spec is unambiguous that `p=` names a - * *document*: - * - * > `p` MUST be the base64 encoding of the 32-byte root document ID (an - * > ed25519 verifying key) — *specs/anchoring/dns-anchor.md:72* - * - * and that a bare key is not an identity at all: - * - * > the key alone is not an identity, since the same key bytes may be - * > delegated in more than one document — *dns-anchor.md:144* - * - * So a `p=` naming an individual is a configuration the spec does not - * define, and this function grades it `designates` anyway. That is fine for - * a **stub or a test**, where the point is a deterministic outcome with no - * keyhive documents behind it, and wrong for anything a person reads as - * proof: there is no document, so there can be no certificate, so no third - * party can be shown why the verdict was reached. + * **Not a conformant production check.** The onomancy spec requires `p=` to + * name a root document (specs/anchoring/dns-anchor.md §TXT fields); a bare + * key carries no certificate, so nothing about it is checkable by a third + * party. This function grades id equality `designates` anyway, which is fine + * for a stub or a test — a deterministic outcome with no keyhive documents + * behind it — and wrong for anything a person reads as proof. * * Use {@link createKeyhiveDesignation} for real bindings. * @@ -74,18 +62,11 @@ export type KeyhiveDesignationOptions = DocumentDelegationOptions; * A composition, not an implementation. The DNS half is here; the keyhive * half is {@link documentDelegatesTo}, which knows nothing about domains. * - * **No solo short-circuit.** An earlier version accepted a `p=` that named - * the identity's own key directly, on the reading that a "solo publisher" - * may anchor to their key. That conflated two different uses of the word: - * the spec's solo allowance is about **`g=`**, permitting a solo publisher's - * *generation key* to be their own admin key (`doc → admin`, the chain - * trivially passing through) — it is not permission for `p=` to skip the - * document. - * - * A `p=` naming an individual therefore reaches `documentDelegatesTo`, finds - * no document under that id, and grades `unknown`: not refuted, not proven. - * Which is what it is — a bare key can carry no certificate, so nothing about - * it is checkable by anyone but the verifier that computed it. + * There is no bare-key case: `p=` MUST name a root document + * (specs/anchoring/dns-anchor.md §TXT fields), and a key alone is not an + * identity — the same bytes may be delegated in more than one document. A + * `p=` naming an individual reaches `documentDelegatesTo`, finds no document + * under that id, and grades `unknown`: not refuted, not proven. * * Inherits {@link documentDelegatesTo}'s limit — an identity holding admin * through a nested group reads `unknown`, never `excludes`. diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index b21877a..678fb7b 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -3,7 +3,7 @@ * * Imported as `@automerge/keyhive-react/onomancy`, separately from the main * entry point. The split follows the domains rather than the layers: the - * main entry knows what a DNS name claim is and what the six statuses mean, + * main entry knows what a DNS name claim is and what the twelve statuses mean, * and renders them; everything that *resolves* a name lives here. * * That makes this subpath optional in practice. An application that diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index bd5b2e2..3dc6977 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -211,22 +211,11 @@ export function createOnomancyRuntime( } if (freshness !== undefined) binding.freshness = freshness; - // The window and the clock reading are the *inputs* to the grade, - // returned beside it so a caller can check the work rather than take - // the verdict on faith. - // - // Both are OPTIONAL and must stay so. This runtime takes an injected - // module, so the build in play is whatever the consumer installed, not - // whatever we tested against. Observed shapes, by artifact: - // - // sha256 2d8eab4f… { hostname, links, freshness, records, window, checkedAt } - // earlier 0.1.0 { hostname, links, freshness, records } - // - // The version string was `0.1.0` for both. A reader who checks against - // "0.1.0" and finds four keys is not looking at the same artifact, so - // the sha is the only identifier that means anything here. Verified - // against both: the four-key build yields freshness with the magnitude - // fields omitted, and nothing throws. + // The window and the clock reading are the inputs to the grade, + // returned beside it so a caller can check the work. Both are OPTIONAL + // and must stay so: the module is injected, so the build in play is + // whatever the consumer installed, and older builds omit these fields. + // Absence degrades to "no magnitude", never to a throw. const chainWindow = validityWindowOf(outcome); const checkedAt = finiteSeconds( (outcome as { checkedAt?: unknown } | null)?.checkedAt @@ -251,17 +240,11 @@ export function createOnomancyRuntime( }, normalizeDnsName: (raw) => { - // Guarded here rather than left to the module, because the module in - // play is whatever the CONSUMER installed. Builds before - // sha256 `308b6e30…` trap on a non-string with - // `RuntimeError: memory access out of bounds` — an unrecoverable Wasm - // fault, not a catchable error. Later builds reject cleanly with - // `a name must be a string`. - // - // A library cannot choose its consumer's build, so the guard stays - // even though upstream has since repaired it: for anyone still on an - // older artifact this is the difference between a caught error and a - // dead module. It costs one `typeof`. + // Guarded here rather than left to the module: a library cannot + // choose its consumer's build, and older onomancy builds trap on a + // non-string with `RuntimeError: memory access out of bounds` — an + // unrecoverable Wasm fault, not a catchable error. One `typeof` is the + // difference between a caught error and a dead module. if (typeof raw !== "string") { throw new TypeError( `A DNS name claim must be a string, got ${typeof raw}` diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index c9ce746..c886a79 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -51,10 +51,8 @@ type Verdict = * `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 * nothing to say about it. It goes stale for an entirely local reason — the - * document arrives — and can be re-checked today, which is what - * {@link clearVerificationVerdicts} is for. - * - * Holding them in one map made the second look blocked on the first. + * document arrives — so {@link clearVerificationVerdicts} re-checks it + * without discarding resolutions. */ /** * One `subscribe` call. @@ -267,15 +265,11 @@ export function createOnomancyDirectory( const existing = resolutions.get(hostname); if (existing) return existing; - // Decided HERE, from the claim itself, rather than by reading the error - // that a query would produce. A syntactically impossible hostname cannot - // be looked up, so there is nothing to wait for and no network to blame - // — and we can say so without a round trip. - // - // Deliberately not classified from message text. Two of the strings this - // would have matched on changed upstream within one afternoon, so a - // classifier built on them would have broken twice in a day. Anything we - // cannot determine structurally stays in the conservative bucket. + // Decided here, from the claim itself: a syntactically impossible + // hostname cannot be looked up, so there is nothing to wait for and no + // network to blame. Never classified from message text — error strings + // are prose, not a contract; anything not determined structurally stays + // in the conservative bucket. if (!isSyntacticallyResolvable(hostname)) { const malformed: Resolution = { phase: "malformed" }; resolutions.set(hostname, malformed); @@ -421,11 +415,10 @@ export function createOnomancyDirectory( // collapsed agreeing duplicates, so more than one id means two records // of equal precedence name different documents. // - // This cannot go through `verdictFor`, because every verdict it can - // return is an answer about *this entry* — and the zone has not made - // one. Routing it there yielded `unknown`, which rendered as `unsynced`: - // "wait for a document to arrive", when nothing is arriving and the - // remedy belongs to whoever controls the DNS records. + // This must not go through `verdictFor`: every verdict it can return is + // 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) { return { ...entry, dnsNameStatus: "contested" }; } From 74fd3ededad7da4f14114c09fd54f6ece39ca495 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 01:00:36 -0700 Subject: [PATCH 10/16] Strip all verification decorations on publish publish removed dnsNameStatus but let dnsNameFreshness and dnsNameLapsedSeconds through to the base directory, persisting stale verification data the docs say is never stored. All three are computed per lookup; none survives a write. Found by Copilot review on #4. --- src/onomancy/verified-directory.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index c886a79..f25d22e 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -483,8 +483,14 @@ export function createOnomancyDirectory( const publish = base.publish?.bind(base); if (publish) { directory.publish = (entry) => { - // The status is a decoration, never stored. - const { dnsNameStatus: _status, ...stored } = entry; + // Verification results are decorations, never stored: they are computed + // per lookup and would otherwise persist stale in the base directory. + const { + dnsNameStatus: _status, + dnsNameFreshness: _freshness, + dnsNameLapsedSeconds: _lapsed, + ...stored + } = entry; return publish(stored); }; } From 6869a1c06170bec4235b98e24a8a8e10348c5ff9 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 01:00:36 -0700 Subject: [PATCH 11/16] Wrap the listener before subscribing to the base directory subscribe passed the raw callback through to base.subscribe. A base directory that deduplicates listeners by identity (as the demo's localDirectory does) collapses two subscriptions sharing one callback, and the first unsubscribe cancels the second subscriber's base updates. The cache-side Set was already wrapper-per-subscription; the base side now is too. Found by Copilot review on #4. --- src/onomancy/verified-directory.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index f25d22e..45a555d 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -472,7 +472,11 @@ export function createOnomancyDirectory( // rebuild must still reach whoever is listening when it lands. const subscriber: Subscriber = { notify: listener }; listeners.add(subscriber); - const unsubscribe = base.subscribe?.(listener); + // A fresh closure, not the raw listener: a base directory that + // deduplicates listeners by identity would otherwise collapse two + // subscriptions sharing one callback, and the first unsubscribe would + // cancel the second subscriber's base updates. + const unsubscribe = base.subscribe?.(() => listener()); return () => { listeners.delete(subscriber); unsubscribe?.(); From fec70dd9d7bf6ecf43dc75893126f90debbba855 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 13:24:57 -0700 Subject: [PATCH 12/16] Update keyhive -> onomancy --- README.md | 38 +++++++++--------- apps/component-test-app/src/onomancyStub.ts | 4 ++ e2e/dns-names.spec.ts | 2 +- e2e/shared-directory.spec.ts | 4 -- src/access/delegation.ts | 12 +++--- src/directory/types.ts | 12 +++--- src/onomancy/designation.ts | 13 ++++--- src/onomancy/runtime.ts | 14 +++---- src/onomancy/useOnomancyDirectory.ts | 7 ++-- src/onomancy/verified-directory.ts | 43 ++++++++++----------- 10 files changed, 72 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 9e56e66..ac55438 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ An entry can claim a DNS name (`entry.dnsName`), giving an identity a memorable, globally shareable spelling like `@expede.wtf`. The claim is self-asserted until it is verified through [onomancy](https://github.com/inkandswitch/onomancy): the domain publishes a -DNSSEC-protected `_onomancy` TXT record whose `p=` field is the identity's -ed25519 verifying key, and the record is validated locally from the IANA root +DNSSEC-protected `_onomancy` TXT record whose `p=` field names the bound +root document (an ed25519 verifying key), and the record is validated locally +from the IANA root — no registry, no certificate authority, and no trust in whoever relayed it. ### Where the pieces live @@ -150,9 +151,11 @@ A claim is checked once, lazily, the first time its entry is read, and the result lands on the entry as `dnsNameStatus`, one of twelve values — `verified`, `mismatch`, `contested`, `offline`, `malformed`, `no-claim`, `chain-failed`, `replayed`, `deferred`, `unsynced`, `pending`, `invalid`. -The five non-answers are separate values because they carry different -remedies: retry, fix the input, tell the domain owner, wait for a clock, or -trust nothing from this zone. `ContactBook`, +The non-verdicts are separate values because they carry different remedies: +retry (`offline`), fix the input (`malformed`), nothing to prove +(`no-claim`), wait (`unsynced`, `deferred`, `pending`) — and the two +security signals, `chain-failed` and `replayed`, must never be rendered as +absences. `ContactBook`, `AccessEditor`, and `ProfileEditor` render the claim as a `DnsNameBadge`; a directory without the wrapper renders claims as exactly that — claims, visually no stronger than a self-asserted display name. @@ -199,7 +202,8 @@ it can write anything into it, including somebody else's domain. That is fine: This is why the directory abstraction can stay data-only and swappable, why a directory document that anyone holding its id may write is an acceptable place -to keep claims, and why `publish` strips `dnsNameStatus` before writing. +to keep claims, and why `publish` strips every `dnsName*` verification decoration before +writing. ### The errors run one way @@ -236,22 +240,16 @@ The design has **no false positives and real false negatives**, deliberately: - It will not wrongly verify. Every path to `verified` requires positive evidence from outside the document. - It will sometimes fail to verify someone legitimate. A record that fails to - parse reads `no-claim`; a designated document this device has not synced - reads `unsynced`; and an identity holding admin _through a group_ reads - `unsynced` too, because keyhive's `members()` reports a document's own - delegations and those do not change when a group that already has access - gains a member. - -That last one is a real gap and worth stating precisely. Fixing the _wording_ -is possible today; fixing the _verdict_ is not. The only evidence available -about indirect access is `cgkaMembers()`, which returns bare `Identifier`s — -and `Identifier` carries no access level at all, so it can never satisfy an -admin minimum. Verifying a nested-group admin needs transitive delegations -_with_ their capabilities, which no current API exposes. + parse reads `no-claim`, and a designated document this device has not + synced reads `unsynced` — not evidence either way — until a replica + arrives. + +The delegation walk is transitive (`docMemberCapabilities`), so an identity +holding admin through a nested group verifies exactly as a direct admin does, +at the access its chain actually grants. Never wrongly verifying while sometimes failing to verify is the right trade -for a naming system, and the gap above is an instance of that choice rather -than an exception to it. +for a naming system. ## Styling diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts index 766cd99..6ab1a87 100644 --- a/apps/component-test-app/src/onomancyStub.ts +++ b/apps/component-test-app/src/onomancyStub.ts @@ -28,6 +28,10 @@ export function createStubOnomancy(selfIdHex: string): OnomancyModule { case "other.test": return Promise.resolve(outcome(hostname, "ab".repeat(32))); default: + // A plain Error with no `reason` property: the directory's + // conservative fallback maps it to `offline`, and the e2e + // assertions depend on that. Adding a typed reason here changes + // which status renders. return Promise.reject( new Error(`No onomancy binding for ${hostname}`) ); diff --git a/e2e/dns-names.spec.ts b/e2e/dns-names.spec.ts index 6d08897..92be0ea 100644 --- a/e2e/dns-names.spec.ts +++ b/e2e/dns-names.spec.ts @@ -48,7 +48,7 @@ test.describe("DNS names verified through onomancy", () => { ); }); - test("an unresolvable domain is marked unreachable, not failed", async ({ + test("an unresolvable domain is marked offline, not failed", async ({ page, }) => { await openApp(page); diff --git a/e2e/shared-directory.spec.ts b/e2e/shared-directory.spec.ts index a992a5d..7707e0b 100644 --- a/e2e/shared-directory.spec.ts +++ b/e2e/shared-directory.spec.ts @@ -30,10 +30,6 @@ function contactResult(page: Page, name: string) { } test.describe("names shared through a directory document", () => { - // Previously skipped: on @automerge/automerge-repo-keyhive 0.5.0-alpha.5b, - // keyhive delegations synced between profiles (the grant showed up on both - // sides) but Automerge document CONTENTS never arrived at the second - // profile — with Edit here, and with Read in the Document section. test("two identities see each other's names after sharing one directory", async ({ page, browser, diff --git a/src/access/delegation.ts b/src/access/delegation.ts index b1d923d..d66aec3 100644 --- a/src/access/delegation.ts +++ b/src/access/delegation.ts @@ -5,13 +5,11 @@ import type { KeyhiveRuntime } from "../runtime.js"; /** * Whether documents delegate to an identity at a required level. * - * The three values are deliberately not two. `insufficient` is reachable - * only when a delegation naming the identity was found and every one fell - * below the minimum, so it can never mean "not a member" — though it reads - * that way if you skim it. Everything else that is not a clear yes is - * `unknown`: a document this device has not synced, and an identity whose - * access routes through a group, are both the absence of an answer rather - * than a negative one. + * The three values are deliberately not two. `insufficient` means a held + * document was walked — transitively, groups included — and does not grant + * the identity the minimum by any path: positive evidence of non-membership + * or under-delegation. `unknown` means the question could not be answered + * at all, because no named document is held on this device. * * Collapsing `unknown` into `insufficient` is the same error as reporting a * DNS name that could not be resolved as a mismatch. Absence of evidence is diff --git a/src/directory/types.ts b/src/directory/types.ts index 7a9ce48..b65d9f2 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -35,12 +35,14 @@ export type DirectoryEntryKind = "individual" | "group"; * so membership cannot be checked yet. Proves nothing either way. * - `invalid`: the claim is not a DNS name at all. * - * ### Why the non-answers are separate values + * ### Why the non-verdicts are separate values * - * They carry different remedies — retry, fix the input, tell the domain - * owner, wait for a clock, trust nothing from this zone — and the remedy is - * the only thing a badge can act on. Collapsing them tells a user with a - * typo to check their network. + * They carry different remedies, and the remedy is the only thing a badge + * can act on: retry (`offline`), fix the input (`malformed`), nothing to + * prove (`no-claim`), wait (`unsynced`, `deferred`, `pending`). The two + * security signals — `chain-failed`, `replayed` — are not non-answers and + * must never render as absences. Collapsing any of these tells a user with + * a typo to check their network. * * ## Rules for anyone producing a status * diff --git a/src/onomancy/designation.ts b/src/onomancy/designation.ts index 2342702..c1273ef 100644 --- a/src/onomancy/designation.ts +++ b/src/onomancy/designation.ts @@ -64,12 +64,15 @@ export type KeyhiveDesignationOptions = DocumentDelegationOptions; * * There is no bare-key case: `p=` MUST name a root document * (specs/anchoring/dns-anchor.md §TXT fields), and a key alone is not an - * identity — the same bytes may be delegated in more than one document. A - * `p=` naming an individual reaches `documentDelegatesTo`, finds no document - * under that id, and grades `unknown`: not refuted, not proven. + * identity — the same bytes may be delegated in more than one document. The + * spec's "solo publisher" allowance is about `g=` (a solo publisher's + * generation key may be their own admin key); it is not permission for `p=` + * to skip the document. A `p=` naming an individual reaches + * `documentDelegatesTo`, finds no document under that id, and grades + * `unknown`: not refuted, not proven. * - * Inherits {@link documentDelegatesTo}'s limit — an identity holding admin - * through a nested group reads `unknown`, never `excludes`. + * The delegation walk is transitive, so admin held through a nested group + * designates exactly as direct admin does. */ export function createKeyhiveDesignation( runtime: KeyhiveRuntime, diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 3dc6977..2d51d0e 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -46,18 +46,12 @@ export interface OnomancyRuntimeOptions { * Milliseconds since the epoch. Defaults to `Date.now`. * * Injectable because the serial skew bound is a decision about *now*, and - * a test that cannot name the instant can only assert the behaviour it - * happens to observe. Upstream made the same parameter available on chain - * grading for the same reason, and it was the difference between a - * deterministic test and one that agreed with whatever it found. + * a test that cannot name the instant can only assert whatever behaviour + * it happens to observe. */ now?: () => number; } -/** - * A DNSSEC-verified binding: the root document ids a hostname's - * `_onomancy` TXT records designate. - */ /** * How current the DNSSEC chain was when it was graded. * @@ -100,6 +94,10 @@ const MAX_DNS_NAME_LENGTH = 254; */ const SERIAL_SKEW_BOUND_MS = 5n * 60n * 1000n; +/** + * A DNSSEC-verified binding: the root document ids a hostname's + * `_onomancy` TXT records designate. + */ export interface HostnameBinding { hostname: string; /** diff --git a/src/onomancy/useOnomancyDirectory.ts b/src/onomancy/useOnomancyDirectory.ts index 811bb52..cbe0c6a 100644 --- a/src/onomancy/useOnomancyDirectory.ts +++ b/src/onomancy/useOnomancyDirectory.ts @@ -15,10 +15,9 @@ import { * * The wrapper is still memoized on `base`, so a directory backed by a live * Automerge document is rebuilt on every write — the document is a new - * object each time. What no longer happens is the rebuild throwing away - * every verdict with it: the cache lives in a ref, outlives the wrapper, and - * so a rebuild costs a wrapper allocation rather than a fresh DoH round trip - * per claimed hostname. + * object each time. The cache lives in a ref and outlives the wrapper, so a + * rebuild costs a wrapper allocation rather than a fresh DoH round trip per + * claimed hostname. * * Pass `options.cache` to share results more widely, or to hold the handle * you need for `clearVerificationCache`. diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index 45a555d..d610a88 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -22,12 +22,11 @@ type Resolution = freshness?: ChainFreshness; lapsedSeconds?: number; } - // The reason the DNS layer gave no usable answer. These are separate - // phases rather than one `unreachable` because they carry different - // remedies: `offline` says retry, `malformed` says fix the claim, - // `no-claim` says the domain is not claiming anyone and no amount of - // waiting will change that, and `chain-failed` says something arrived and - // failed to verify — the only one of the four with a security reading. + // The reason the DNS layer gave no usable answer, one phase per remedy: + // `offline` says retry, `malformed` says fix the claim, `no-claim` says + // the domain is not claiming anyone, `deferred` says wait for a clock — + // and `chain-failed` and `replayed` are security signals: evidence + // arrived and failed. | { phase: "offline" } | { phase: "malformed" } | { phase: "no-claim" } @@ -54,19 +53,6 @@ type Verdict = * document arrives — so {@link clearVerificationVerdicts} re-checks it * without discarding resolutions. */ -/** - * One `subscribe` call. - * - * A wrapper rather than the bare function, because a `Set` of functions - * deduplicates by identity: two components passing the same stable callback - * — a `useCallback` with no dependencies, say — would register once, and the - * first unsubscribe would silently cancel the second's subscription. Each - * call gets its own object, so registrations count rather than collapse. - */ -interface Subscriber { - readonly notify: () => void; -} - interface CacheState { resolutions: Map; verdicts: Map; @@ -86,6 +72,19 @@ interface CacheState { listeners: Set; } +/** + * One `subscribe` call. + * + * A wrapper rather than the bare function, because a `Set` of functions + * deduplicates by identity: two components passing the same stable callback + * — a `useCallback` with no dependencies, say — would register once, and the + * first unsubscribe would silently cancel the second's subscription. Each + * call gets its own object, so registrations count rather than collapse. + */ +interface Subscriber { + readonly notify: () => void; +} + /** * Verification results held across directory rebuilds. * @@ -510,10 +509,8 @@ function bareId(id: string): string { * Why the DNS layer gave no answer, from the runtime's own `reason` when it * supplies one. * - * Read off a property rather than matched from message text. Two of the - * strings this would otherwise have keyed on changed upstream inside one - * afternoon, so a text classifier would have broken twice in a day. `reason` - * is a contract; the message is prose that happens to be stable. + * Read off a property, never matched from message text: `reason` is a + * contract; the message is prose and may change at any time. * * The mapping is by **remedy**, because that is the only thing the badge can * act on: From 679f6396bdc488c26adc3c1eb72edde4b8a2c78c Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 13:26:38 -0700 Subject: [PATCH 13/16] Address adversarial review: doc drift, listener passthrough, unit pins Three reviewers over 2b43e01..HEAD. The findings: - Docs stated pre-transitive-walk semantics the code no longer has: DelegationVerdict said insufficient could never mean non-membership, designation.ts inherited a removed limit, and the README's group-admin gap paragraph claimed no API exposes transitive delegations with capabilities (docMemberCapabilities is that API and the code uses it). - composeDirectories forwarded the raw listener to both children - the identity-collapse bug class fixed one layer above in 6869a1c. - Both recent bug fixes were unpinned, and the e2e suite provably passes with them reverted (verified empirically against a pre-fix bundle). tests/verified-directory.test.mjs pins them with bare node --test against dist/, wired into pnpm check; both pins fail on pre-fix code. - Wording repairs: the non-answers grouping no longer claims chain-failed and replayed as absences; the g=-vs-p= solo-allowance distinction is restored; two doc comments reattached to the declarations they describe; stale phase counts and the p= description corrected. --- .../src/composeDirectories.ts | 8 +- package.json | 5 +- tests/verified-directory.test.mjs | 97 +++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 tests/verified-directory.test.mjs diff --git a/apps/component-test-app/src/composeDirectories.ts b/apps/component-test-app/src/composeDirectories.ts index 9603e20..645678f 100644 --- a/apps/component-test-app/src/composeDirectories.ts +++ b/apps/component-test-app/src/composeDirectories.ts @@ -53,9 +53,13 @@ export function composeDirectories( }, subscribe(listener) { + // Fresh closures, not the raw listener: a child directory that + // deduplicates listeners by identity would collapse two subscriptions + // sharing one callback, and the first unsubscribe would cancel the + // second subscriber's updates. const subscriptions = [ - primary.subscribe?.(listener), - fallback.subscribe?.(listener), + primary.subscribe?.(() => listener()), + fallback.subscribe?.(() => listener()), ]; return () => { for (const unsubscribe of subscriptions) unsubscribe?.(); diff --git a/package.json b/package.json index 9be0e6f..239a677 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "build": "pnpm run clean && pnpm run build:js && pnpm run build:css && pnpm run check", "build:js": "tsc -p tsconfig.build.json", "build:css": "tailwindcss -c tailwind.config.js -i src/styles.css -o dist/keyhive-react.css --minify", - "check": "pnpm run check:isolation && pnpm run check:prefix", + "check": "pnpm run check:isolation && pnpm run check:prefix && pnpm run test:unit", "check:isolation": "node scripts/check-isolation.mjs", "check:prefix": "node scripts/check-prefix.mjs", "tsc": "tsc -p tsconfig.json", @@ -64,7 +64,8 @@ "app:preview": "pnpm --filter component-test-app preview", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "tsc:e2e": "tsc -p e2e/tsconfig.json" + "tsc:e2e": "tsc -p e2e/tsconfig.json", + "test:unit": "node --test tests/*.test.mjs" }, "peerDependencies": { "@automerge/automerge-repo-keyhive": ">=0.5.0-alpha.6", diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs new file mode 100644 index 0000000..59b8f99 --- /dev/null +++ b/tests/verified-directory.test.mjs @@ -0,0 +1,97 @@ +// 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. +// +// 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"; + +const { + createOnomancyDirectory, + createOnomancyRuntime, + createVerificationCache, +} = await import("../dist/onomancy/index.js"); + +const noopRuntime = () => + createOnomancyRuntime({ + resolveHostname: async () => ({ records: [] }), + Name: class { + constructor() {} + }, + }); + +test("publish strips every verification decoration, nothing else", async () => { + let published; + const base = { + source: "test", + trust: "unverified", + writable: true, + enumerable: true, + notice: "", + lookup: () => undefined, + list: () => [], + publish: (entry) => { + published = entry; + }, + }; + const directory = createOnomancyDirectory(base, noopRuntime(), { + cache: createVerificationCache(), + }); + + directory.publish({ + id: "aa".repeat(32), + name: "Alice", + dnsName: "", + dnsNameStatus: "verified", + dnsNameFreshness: "stale", + dnsNameLapsedSeconds: 3600, + }); + + // Whole-entry equality: decorations gone, everything else — including the + // empty-string dnsName that clears a claim — delivered intact. + assert.deepEqual(published, { + id: "aa".repeat(32), + name: "Alice", + dnsName: "", + }); +}); + +test("two subscriptions sharing one callback survive one unsubscribe", async () => { + const baseListeners = new Set(); + const base = { + source: "test", + trust: "unverified", + writable: false, + enumerable: true, + notice: "", + lookup: () => undefined, + list: () => [], + // An identity-deduplicating base, like the demo's localDirectory. + subscribe: (fn) => { + baseListeners.add(fn); + return () => baseListeners.delete(fn); + }, + }; + const directory = createOnomancyDirectory(base, noopRuntime(), { + cache: createVerificationCache(), + }); + + let hits = 0; + const shared = () => hits++; + const offA = directory.subscribe(shared); + directory.subscribe(shared); + + assert.equal( + baseListeners.size, + 2, + "each subscribe registers its own closure" + ); + + offA(); + assert.equal(baseListeners.size, 1, "one unsubscribe removes exactly one"); + + for (const fn of baseListeners) fn(); + assert.equal(hits, 1, "the surviving subscription still fires"); +}); From 3fa1b9f0a026ed2484d5a8c791078e8ee66fa059 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 13:28:27 -0700 Subject: [PATCH 14/16] Rename package to @inkandswitch/onomancy-react Published as a fork of @automerge/keyhive-react. The npm name, repository metadata, CSS artifact (dist/onomancy-react.css), flake derivation names, and every import in the test app and e2e suite move to the new name. The technology names are unchanged on purpose: KeyhiveRuntime, the @automerge/automerge-repo-keyhive peer dependency, and the kh- CSS class prefix all refer to keyhive the system, which this package still wraps. --- README.md | 18 +++++++++--------- apps/component-test-app/README.md | 14 +++++++------- apps/component-test-app/package.json | 2 +- apps/component-test-app/src/App.tsx | 8 ++++---- apps/component-test-app/src/DocumentPanel.tsx | 2 +- .../src/composeDirectories.ts | 5 ++++- apps/component-test-app/src/keyhiveRuntime.ts | 4 ++-- apps/component-test-app/src/localDirectory.ts | 2 +- apps/component-test-app/src/main.tsx | 2 +- apps/component-test-app/src/nameResolution.ts | 7 +++++-- apps/component-test-app/src/onomancyStub.ts | 2 +- e2e/helpers.ts | 2 +- flake.nix | 18 +++++++++--------- package.json | 12 ++++++------ pnpm-lock.yaml | 6 +++--- scripts/check-isolation.mjs | 2 +- scripts/check-prefix.mjs | 2 +- src/components/AccountView.tsx | 2 +- src/components/ProfileEditor.tsx | 2 +- src/directory/types.ts | 2 +- src/index.ts | 2 +- src/onomancy/index.ts | 2 +- tailwind.config.js | 2 +- 23 files changed, 63 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ac55438..0b4aaff 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @automerge/keyhive-react +# @inkandswitch/onomancy-react React components for applications that use keyhive. @@ -7,14 +7,14 @@ Pre-alpha. ## Install ``` -pnpm add @automerge/keyhive-react +pnpm add @inkandswitch/onomancy-react ``` `@automerge/automerge-repo-keyhive`, `@automerge/react` and `react` are peer dependencies. The package imports none of them at runtime (see [The keyhive runtime](#the-keyhive-runtime)), so the application's copy is the only one loaded. [DNS names](#dns-names) work the same way, through the -separate `@automerge/keyhive-react/onomancy` entry point. +separate `@inkandswitch/onomancy-react/onomancy` entry point. ## What is in it @@ -35,8 +35,8 @@ import { DirectoryProvider, AccessEditor, useKeyhiveUpdates, -} from "@automerge/keyhive-react"; -import "@automerge/keyhive-react/styles.css"; +} from "@inkandswitch/onomancy-react"; +import "@inkandswitch/onomancy-react/styles.css"; const runtime = createKeyhiveRuntime(ark); @@ -114,8 +114,8 @@ them; it resolves nothing. Everything that performs DNS lives behind a separate import: ```ts -import { DnsNameBadge, type DnsNameStatus } from "@automerge/keyhive-react"; -import { useOnomancyDirectory } from "@automerge/keyhive-react/onomancy"; +import { DnsNameBadge, type DnsNameStatus } from "@inkandswitch/onomancy-react"; +import { useOnomancyDirectory } from "@inkandswitch/onomancy-react/onomancy"; ``` So the subpath is optional in practice. An application that computes @@ -136,7 +136,7 @@ import * as onomancy from "@inkandswitch/onomancy"; import { createOnomancyRuntime, useOnomancyDirectory, -} from "@automerge/keyhive-react/onomancy"; +} from "@inkandswitch/onomancy-react/onomancy"; const onomancyRuntime = createOnomancyRuntime(onomancy); @@ -254,7 +254,7 @@ for a naming system. ## Styling ```ts -import "@automerge/keyhive-react/styles.css"; +import "@inkandswitch/onomancy-react/styles.css"; ``` Every class is prefixed `kh-` and every custom property `--kh-`, so the diff --git a/apps/component-test-app/README.md b/apps/component-test-app/README.md index 8eb5b3e..dd5eccb 100644 --- a/apps/component-test-app/README.md +++ b/apps/component-test-app/README.md @@ -6,13 +6,13 @@ rather than the published package to exercise the working tree. It was designed to contrast with the TODO demo in [keyhive-todo-app-demo](https://github.com/inkandswitch/keyhive-todo-app-demo): -| | TODO demo | This app | -| ----------------- | -------------------------- | ------------------------------------------ | -| Name directory | shared Automerge phonebook | localStorage, per browser | -| Directory updates | new object each change | `subscribe` callbacks | -| Styling | its own Tailwind setup | `@automerge/keyhive-react/styles.css` only | -| Theme | dark | light | -| Component context | dialogs | inline sections | +| | TODO demo | This app | +| ----------------- | -------------------------- | ---------------------------------------------- | +| Name directory | shared Automerge phonebook | localStorage, per browser | +| Directory updates | new object each change | `subscribe` callbacks | +| Styling | its own Tailwind setup | `@inkandswitch/onomancy-react/styles.css` only | +| Theme | dark | light | +| Component context | dialogs | inline sections | ## Run diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index b939b4e..c0dd116 100644 --- a/apps/component-test-app/package.json +++ b/apps/component-test-app/package.json @@ -14,7 +14,7 @@ "@automerge/automerge-repo-keyhive": "0.5.0-alpha.6", "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.48", "@automerge/automerge-subduction": "0.16.1", - "@automerge/keyhive-react": "workspace:*", + "@inkandswitch/onomancy-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", "@inkandswitch/onomancy": "0.2.0", "@keyhive/keyhive": "0.1.0-alpha.8", diff --git a/apps/component-test-app/src/App.tsx b/apps/component-test-app/src/App.tsx index 7d4576c..1df98b7 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -28,14 +28,14 @@ import { ProfileEditor, useAutomergeDocDirectory, useKeyhiveUpdates, -} from "@automerge/keyhive-react"; +} from "@inkandswitch/onomancy-react"; import { createKeyhiveDesignation, createOnomancyRuntime, idEqualityDesignation, useOnomancyDirectory, type DnsDesignation, -} from "@automerge/keyhive-react/onomancy"; +} from "@inkandswitch/onomancy-react/onomancy"; import { composeDirectories } from "./composeDirectories"; import { DocumentPanel, LoadDocument } from "./DocumentPanel"; import { @@ -72,7 +72,7 @@ interface AppProps { } /** - * A test app for the keyhive-react components. + * A test app for the onomancy-react components. */ export default function App({ hive, repo }: AppProps) { // The localStorage copy: always available, never shared. @@ -363,7 +363,7 @@ function TestApp({ return (

-

keyhive-react test app

+

onomancy-react test app

{error && ( diff --git a/apps/component-test-app/src/DocumentPanel.tsx b/apps/component-test-app/src/DocumentPanel.tsx index 9c6373d..126b04d 100644 --- a/apps/component-test-app/src/DocumentPanel.tsx +++ b/apps/component-test-app/src/DocumentPanel.tsx @@ -13,7 +13,7 @@ import type { import { CopyableField, useReRenderOnDocProgress, -} from "@automerge/keyhive-react"; +} from "@inkandswitch/onomancy-react"; export interface TestAppDoc { title: string; diff --git a/apps/component-test-app/src/composeDirectories.ts b/apps/component-test-app/src/composeDirectories.ts index 645678f..922382a 100644 --- a/apps/component-test-app/src/composeDirectories.ts +++ b/apps/component-test-app/src/composeDirectories.ts @@ -1,4 +1,7 @@ -import type { DirectoryEntry, NameDirectory } from "@automerge/keyhive-react"; +import type { + DirectoryEntry, + NameDirectory, +} from "@inkandswitch/onomancy-react"; function definedFields(entry: DirectoryEntry): DirectoryEntry { const out = { ...entry }; diff --git a/apps/component-test-app/src/keyhiveRuntime.ts b/apps/component-test-app/src/keyhiveRuntime.ts index eb0cc9c..50de0aa 100644 --- a/apps/component-test-app/src/keyhiveRuntime.ts +++ b/apps/component-test-app/src/keyhiveRuntime.ts @@ -1,5 +1,5 @@ import * as ark from "@automerge/automerge-repo-keyhive"; -import { createKeyhiveRuntime } from "@automerge/keyhive-react"; +import { createKeyhiveRuntime } from "@inkandswitch/onomancy-react"; -// The only route by which keyhive-react reaches the keyhive packages. +// The only route by which onomancy-react reaches the keyhive packages. export const keyhiveRuntime = createKeyhiveRuntime(ark); diff --git a/apps/component-test-app/src/localDirectory.ts b/apps/component-test-app/src/localDirectory.ts index e7ee2b5..e12f9cd 100644 --- a/apps/component-test-app/src/localDirectory.ts +++ b/apps/component-test-app/src/localDirectory.ts @@ -2,7 +2,7 @@ import type { DirectoryEntry, DirectoryEntryKind, NameDirectory, -} from "@automerge/keyhive-react"; +} from "@inkandswitch/onomancy-react"; /** * A name directory kept in localStorage. Its contents live outside React so it diff --git a/apps/component-test-app/src/main.tsx b/apps/component-test-app/src/main.tsx index 11ce143..355f111 100644 --- a/apps/component-test-app/src/main.tsx +++ b/apps/component-test-app/src/main.tsx @@ -9,7 +9,7 @@ import { Repo } from "@automerge/automerge-repo"; import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"; import { RepoContext } from "@automerge/react/slim"; // The only CSS the components need. This app has no Tailwind of its own. -import "@automerge/keyhive-react/styles.css"; +import "@inkandswitch/onomancy-react/styles.css"; import "./app.css"; import App from "./App"; diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts index f8a6117..46469ad 100644 --- a/apps/component-test-app/src/nameResolution.ts +++ b/apps/component-test-app/src/nameResolution.ts @@ -4,8 +4,11 @@ import { type AutomergeUrl, type Repo, } from "@automerge/react/slim"; -import { hexToBytes, RESERVED_ONOMANCY_KEY } from "@automerge/keyhive-react"; -import type { OnomancyRuntime } from "@automerge/keyhive-react/onomancy"; +import { + hexToBytes, + RESERVED_ONOMANCY_KEY, +} from "@inkandswitch/onomancy-react"; +import type { OnomancyRuntime } from "@inkandswitch/onomancy-react/onomancy"; /** * The path-resolution walk over locally held documents, per the onomancy diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts index 6ab1a87..c44326c 100644 --- a/apps/component-test-app/src/onomancyStub.ts +++ b/apps/component-test-app/src/onomancyStub.ts @@ -1,5 +1,5 @@ import * as onomancy from "@inkandswitch/onomancy"; -import type { OnomancyModule } from "@automerge/keyhive-react/onomancy"; +import type { OnomancyModule } from "@inkandswitch/onomancy-react/onomancy"; /** * Real onomancy for real domains; deterministic outcomes under `.test`, so diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c3a9628..070ea62 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -6,7 +6,7 @@ import { expect, type Page, type Browser } from "@playwright/test"; export async function openApp(page: Page): Promise { await page.goto("/"); await expect( - page.getByRole("heading", { name: "keyhive-react test app" }) + page.getByRole("heading", { name: "onomancy-react test app" }) ).toBeVisible(); await expect(contactCard(page)).not.toBeEmpty({ timeout: 60_000 }); } diff --git a/flake.nix b/flake.nix index 697bde9..c30f0de 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "keyhive-react"; + description = "onomancy-react"; inputs = { nixpkgs.url = "nixpkgs/nixos-26.05"; @@ -42,7 +42,7 @@ mkCheck = name: text: pkgs.writeShellApplication { - name = "keyhive-react-${name}"; + name = "onomancy-react-${name}"; runtimeInputs = js-env; text = '' set -x @@ -82,12 +82,12 @@ }; ci-all = pkgs.writeShellApplication { - name = "keyhive-react-ci"; + name = "onomancy-react-ci"; runtimeInputs = js-env ++ pkgs.lib.attrValues ci-checks; text = '' pnpm install --frozen-lockfile ${pkgs.lib.concatMapStringsSep "\n" - (check: "keyhive-react-${check}") + (check: "onomancy-react-${check}") (builtins.attrNames ci-checks)} ''; }; @@ -96,7 +96,7 @@ # contexts are two keyhive identities. Not in the `ci` aggregate: # pulls whole browsers — run deliberately. ci-e2e = pkgs.writeShellApplication { - name = "keyhive-react-ci-e2e"; + name = "onomancy-react-ci-e2e"; runtimeInputs = js-env; text = '' export PLAYWRIGHT_BROWSERS_PATH="${playwright.browsers}" @@ -126,7 +126,7 @@ ''; "test:e2e" = cmd "Playwright tests against the test app (extra args pass through)" '' - exec ${ci-e2e}/bin/keyhive-react-ci-e2e "$@" + exec ${ci-e2e}/bin/onomancy-react-ci-e2e "$@" ''; "test:e2e:ui" = cmd "Playwright tests in UI mode" '' @@ -136,13 +136,13 @@ ''; "ci" = cmd "Run all cheap CI checks (lint, tsc, build, pack, app)" '' - exec ${ci-all}/bin/keyhive-react-ci + exec ${ci-all}/bin/onomancy-react-ci ''; }) ]; in { devShells.default = pkgs.mkShell { - name = "keyhive-react_shell"; + name = "onomancy-react_shell"; nativeBuildInputs = command_menu @@ -166,7 +166,7 @@ apps = pkgs.lib.mapAttrs (name: check: { type = "app"; - program = "${check}/bin/keyhive-react-${name}"; + program = "${check}/bin/onomancy-react-${name}"; }) (ci-checks // { diff --git a/package.json b/package.json index 239a677..a6f617e 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@automerge/keyhive-react", + "name": "@inkandswitch/onomancy-react", "version": "0.1.0-alpha.5", "description": "React components and hooks for keyhive access control.", "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/jtfmumm/keyhive-react.git" + "url": "git+https://github.com/inkandswitch/onomancy-react.git" }, - "homepage": "https://github.com/jtfmumm/keyhive-react#readme", - "bugs": "https://github.com/jtfmumm/keyhive-react/issues", + "homepage": "https://github.com/inkandswitch/onomancy-react#readme", + "bugs": "https://github.com/inkandswitch/onomancy-react/issues", "keywords": [ "keyhive", "automerge", @@ -28,7 +28,7 @@ "types": "./dist/onomancy/index.d.ts", "import": "./dist/onomancy/index.js" }, - "./styles.css": "./dist/keyhive-react.css", + "./styles.css": "./dist/onomancy-react.css", "./package.json": "./package.json" }, "files": [ @@ -49,7 +49,7 @@ "scripts": { "build": "pnpm run clean && pnpm run build:js && pnpm run build:css && pnpm run check", "build:js": "tsc -p tsconfig.build.json", - "build:css": "tailwindcss -c tailwind.config.js -i src/styles.css -o dist/keyhive-react.css --minify", + "build:css": "tailwindcss -c tailwind.config.js -i src/styles.css -o dist/onomancy-react.css --minify", "check": "pnpm run check:isolation && pnpm run check:prefix && pnpm run test:unit", "check:isolation": "node scripts/check-isolation.mjs", "check:prefix": "node scripts/check-prefix.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36f0673..509c03e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,15 +78,15 @@ importers: '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 - '@automerge/keyhive-react': - specifier: workspace:* - version: link:../.. '@automerge/react': 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 + '@inkandswitch/onomancy-react': + specifier: workspace:* + version: link:../.. '@keyhive/keyhive': specifier: 0.1.0-alpha.8 version: 0.1.0-alpha.8 diff --git a/scripts/check-isolation.mjs b/scripts/check-isolation.mjs index d5db3fb..9a28783 100644 --- a/scripts/check-isolation.mjs +++ b/scripts/check-isolation.mjs @@ -36,7 +36,7 @@ for (const file of jsFiles(DIST)) { if (violations.length > 0) { console.error( - "keyhive-react must not import anything but React at runtime.\n" + + "onomancy-react must not import anything but React at runtime.\n" + "Take the value from KeyhiveRuntime instead, or use import type.\n" ); for (const violation of violations) console.error(` ${violation}`); diff --git a/scripts/check-prefix.mjs b/scripts/check-prefix.mjs index 2120d9f..60fdfc4 100644 --- a/scripts/check-prefix.mjs +++ b/scripts/check-prefix.mjs @@ -70,7 +70,7 @@ const unprefixed = [...new Set(generated)] if (unprefixed.length > 0) { console.error( "These Tailwind classes are used without the kh- prefix, so they are\n" + - "missing from dist/keyhive-react.css:\n" + "missing from dist/onomancy-react.css:\n" ); for (const name of unprefixed) console.error(` ${name}`); console.error( diff --git a/src/components/AccountView.tsx b/src/components/AccountView.tsx index 28b842f..f17ea11 100644 --- a/src/components/AccountView.tsx +++ b/src/components/AccountView.tsx @@ -19,7 +19,7 @@ export interface AccountViewProps { /** * Canonicalise and validate a typed DNS name claim. Forwarded to * `ProfileEditor`; pass `runtime.normalizeDnsName` from - * `@automerge/keyhive-react/onomancy` to reject bad claims at entry. + * `@inkandswitch/onomancy-react/onomancy` to reject bad claims at entry. */ normalizeDnsName?: (raw: string) => string; /** diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 0b74c85..79855e2 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -25,7 +25,7 @@ export interface ProfileEditorProps { * name; the message is shown to the user and nothing is published. * * Pass `runtime.normalizeDnsName` from - * `@automerge/keyhive-react/onomancy` to reject bad claims at entry + * `@inkandswitch/onomancy-react/onomancy` to reject bad claims at entry * against the real grammar. Without it this field still canonicalises * spelling — trimming, lowercasing, dropping a leading `@` and a trailing * dot — but cannot tell a hostname from a typo, and an unparseable claim diff --git a/src/directory/types.ts b/src/directory/types.ts index b65d9f2..fb2cb37 100644 --- a/src/directory/types.ts +++ b/src/directory/types.ts @@ -156,7 +156,7 @@ export interface DirectoryEntry { dnsName?: string; /** * Set by whatever verifies claims — `createOnomancyDirectory` from - * `@automerge/keyhive-react/onomancy`, or the application's own + * `@inkandswitch/onomancy-react/onomancy`, or the application's own * equivalent. A decoration, never stored: directories strip it on * publish. Absent when the entry claims no DNS name, or when nothing in * scope verifies. diff --git a/src/index.ts b/src/index.ts index 245acc2..ce23d81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ export type { } from "./directory/automerge-directory.js"; export { useAutomergeDocDirectory } from "./directory/useAutomergeDocDirectory.js"; -// DNS name verification lives in `@automerge/keyhive-react/onomancy`. This +// 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. diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index 678fb7b..ee2d38b 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -1,7 +1,7 @@ /** * DNS name verification through onomancy. * - * Imported as `@automerge/keyhive-react/onomancy`, separately from the main + * Imported as `@inkandswitch/onomancy-react/onomancy`, separately from the main * entry point. The split follows the domains rather than the layers: the * main entry knows what a DNS name claim is and what the twelve statuses mean, * and renders them; everything that *resolves* a name lives here. diff --git a/tailwind.config.js b/tailwind.config.js index 530ee84..ea55e98 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,5 @@ /** - * Builds dist/keyhive-react.css. + * Builds dist/onomancy-react.css. * * Every utility is prefixed `kh-` and every custom property `--kh-`, so the * output drops into an application that has no Tailwind and sits beside one From 74f712d0e914eed3da5a15af7441346e5a848684 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 13:58:48 -0700 Subject: [PATCH 15/16] Adjust version --- package.json | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index a6f617e..878564c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inkandswitch/onomancy-react", - "version": "0.1.0-alpha.5", + "version": "0.1.0", "description": "React components and hooks for keyhive access control.", "license": "MIT", "repository": { @@ -31,14 +31,8 @@ "./styles.css": "./dist/onomancy-react.css", "./package.json": "./package.json" }, - "files": [ - "dist", - "src", - "README.md" - ], - "sideEffects": [ - "*.css" - ], + "files": ["dist", "src", "README.md"], + "sideEffects": ["*.css"], "publishConfig": { "access": "public" }, From ab7bbd3e249e03c03aeb643280ff963efde04d5c Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 14:05:47 -0700 Subject: [PATCH 16/16] Restore Prettier formatting in package.json The version-adjust commit hand-collapsed the files and sideEffects arrays onto single lines; prettier --check refuses that shape, which is what failed CI on 74f712d. Content unchanged. --- package.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 878564c..8edbec8 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,14 @@ "./styles.css": "./dist/onomancy-react.css", "./package.json": "./package.json" }, - "files": ["dist", "src", "README.md"], - "sideEffects": ["*.css"], + "files": [ + "dist", + "src", + "README.md" + ], + "sideEffects": [ + "*.css" + ], "publishConfig": { "access": "public" },