diff --git a/demo/e2e/run.ts b/demo/e2e/run.ts index 303f323..54ae941 100644 --- a/demo/e2e/run.ts +++ b/demo/e2e/run.ts @@ -57,6 +57,7 @@ import devicePairingMock from "./scenarios/device-pairing-mock.ts"; import soloPairing from "./scenarios/solo-pairing.ts"; import soloPersistence from "./scenarios/solo-persistence.ts"; import soloErase from "./scenarios/solo-erase.ts"; +import soloResumeSync from "./scenarios/solo-resume-sync.ts"; import soloEphemeral from "./scenarios/solo-ephemeral.ts"; import soloStorage from "./scenarios/solo-storage.ts"; import soloGdrive from "./scenarios/solo-gdrive.ts"; @@ -132,6 +133,16 @@ const SCENARIOS: Scenario[] = [ // fault is in the erasure rather than in anything the device does // between ceremonies. soloErase, + // AND THE STATE BETWEEN CEREMONIES, which is where the account lives + // most of the time: both devices of a paired account close and reopen, + // and sync has to come back WITHOUT a ceremony. It runs after + // solo-pairing (which proves the ceremony) and after solo-persistence + // (which proves a device survives a reload with the same transport + // address) because it depends on both: a failure here with those two + // green says the fault is in the resume wiring — the device directory, + // the role read out of it, the acceptor and the dial — rather than in + // anything either of them covers. + soloResumeSync, soloEphemeral, // THE WORKER HOST'S STORAGE EGRESS (STORAGE-EGRESS.md's T-E): the same // sheet the two device-store scenarios above just proved a device diff --git a/demo/e2e/scenarios/solo-pairing.ts b/demo/e2e/scenarios/solo-pairing.ts index 5010bc1..64e91c5 100644 --- a/demo/e2e/scenarios/solo-pairing.ts +++ b/demo/e2e/scenarios/solo-pairing.ts @@ -49,7 +49,19 @@ import { act, assert, assertEquals, SOLO_KEYS } from "../util.ts"; // The solo pages' shared driving surface — the `__solo` root, the // sandboxed todomvc frame, and the pacing rule for typing into it. Three // scenarios drive this page now; see e2e/solo-util.ts. -import { addTodo, appFrame, solo, stripPersonal, todoRows, until, WAITS } from "../solo-util.ts"; +import { + addTodo, + appFrame, + nameApp, + solo, + stripPersonal, + todoRows, + until, + WAITS, +} from "../solo-util.ts"; +// The strip's own two lines and the pet icon on them — page-generic DOM +// reads, so they work on the solo page exactly as they do on the demo's. +import { stripMarkIcon, stripText } from "../util.ts"; /** The account's own face: the name A commits before pairing and the * anchor colour it picks. Both must be on B's strip after B joins — and @@ -57,6 +69,18 @@ import { addTodo, appFrame, solo, stripPersonal, todoRows, until, WAITS } from " * unchanged strip cannot pass the assertion by accident. */ const ACCOUNT_NAME = "Ada"; const ACCOUNT_HUE = 175; +/** And A's own GLYPH. From the visor's own vocabulary (visor.ts's + * VISOR_ICONS) and deliberately NOT ⛨ — the default an unset record + * renders — so a B whose icon never synced cannot pass by wearing the + * fallback. It is also outside `APP_MARK_ICONS`, so nothing can confuse + * the user's glyph with the app's mark below. */ +const ACCOUNT_ICON = "\u263E"; // ☾ U+263E LAST QUARTER MOON + +/** What A calls the app, through the naming ceremony. The mark that + * goes with it is whatever the ceremony offers (a fresh random draw — + * see `nameApp`), so it is captured at ceremony time rather than named + * here. */ +const APP_PETNAME = "the tasks"; const scenario: Scenario = { name: "solo-pairing", @@ -120,7 +144,7 @@ const scenario: Scenario = { 15_000, ); await pageA.evaluate( - ([who, hue]) => { + ([who, hue, glyph]) => { const input = document.getElementById("visor-settings-name") as HTMLInputElement | null; if (input) input.value = who as string; // A hue that is certainly NOT the seeded 265 both pages boot @@ -129,11 +153,18 @@ const scenario: Scenario = { (document.querySelector( `.settings-hues button[data-hue="${hue}"]`, ) as HTMLButtonElement | null)?.click(); + // AND THE GLYPH, off the sheet's own picker row — the same + // gesture as the hue, one control along. It rides the same + // commit, so the write-through carries name, colour and icon + // in one `us-profile-set`. + (document.querySelector( + `.settings-icons button[data-glyph="${glyph}"]`, + ) as HTMLButtonElement | null)?.click(); (document.querySelector(".settings-sheet .cred-row button:first-child") as | HTMLButtonElement | null)?.click(); }, - [ACCOUNT_NAME, ACCOUNT_HUE] as [string, number], + [ACCOUNT_NAME, ACCOUNT_HUE, ACCOUNT_ICON] as [string, number, string], ); const personal = await until( [pageA], @@ -148,6 +179,7 @@ const scenario: Scenario = { personal.anchorColour.includes(String(ACCOUNT_HUE)), `A's anchor took the picked colour: ${JSON.stringify(personal)}`, ); + assertEquals(personal.icon, ACCOUNT_ICON, "A's own glyph on A's bar"); }); await act("two todos typed into A's real app frame reach A's engine", async () => { @@ -166,6 +198,27 @@ const scenario: Scenario = { assert(titles.includes("call the bank"), `A's todos: ${JSON.stringify(titles)}`); }); + // WHAT A CALLS THE APP, captured at ceremony time: the sheet's offers + // are a fresh random draw, so the glyph is the ceremony's answer + // rather than this file's guess (see `nameApp`). + let appMark = ""; + + await act("A names the app through the visor's own naming ceremony", async () => { + // THE HONEST PATH AGAIN, and the other direction the account + // syncs: the trust table's mark, not the identity record's. A taps + // the strip's context cluster, types a name, picks a glyph and + // Saves — solo.ts's `onNamed` then writes it through to the + // account (`us-mark-put`). What B does with it is the claim below. + appMark = await nameApp(pageA, APP_PETNAME); + assertEquals( + await stripMarkIcon(pageA), + appMark, + "A's strip wears the mark A just picked", + ); + const top = (await stripText(pageA)).top; + assert(top.includes(APP_PETNAME), `A's strip names the app: ${JSON.stringify(top)}`); + }); + // --- PAGE B: a genuinely separate device --------------------------- // // `ctx.fresh()` a SECOND time. The harness supports it (each call @@ -312,6 +365,31 @@ const scenario: Scenario = { personal.anchorColour.includes(String(ACCOUNT_HUE)), `B's anchor took the account's colour: ${JSON.stringify(personal)}`, ); + // AND A'S GLYPH. It crossed as UTF-8 bytes in the account's + // profile (engine.wit's `us-profile.icon`) and was vetted against + // the visor's vocabulary on the way in; ⛨ here would mean B is + // wearing the default, i.e. that nothing arrived. + assertEquals(personal.icon, ACCOUNT_ICON, "the account's glyph on B's bar"); + }); + + await act("B's app wears A's petname and A's mark, from the account's table", async () => { + // THE MARKS HALF OF THE SAME SYNC, and the beat that used to be + // missing: B's engine had the mark all along (the act at the end + // of this scenario proves the document converged), but nothing + // seeded B's own trust table from it, so B mounted the app UNNAMED + // — a paired device greeting the user's own app as a stranger. + // Read off the strip, because that is where the user would see it. + const top = await until( + [pageA, pageB], + "A's petname for the app on B's strip", + async () => { + const t = (await stripText(pageB)).top; + return t.includes(APP_PETNAME) ? t : false; + }, + WAITS.converge, + ); + assert(top.includes(APP_PETNAME), `B's strip: ${JSON.stringify(top)}`); + assertEquals(await stripMarkIcon(pageB), appMark, "A's mark on B's strip"); }); await act("a todo added on B appears on A", async () => { diff --git a/demo/e2e/scenarios/solo-persistence.ts b/demo/e2e/scenarios/solo-persistence.ts index e0578f8..78d339d 100644 --- a/demo/e2e/scenarios/solo-persistence.ts +++ b/demo/e2e/scenarios/solo-persistence.ts @@ -169,6 +169,14 @@ const scenario: Scenario = { assert(trace.includes("promoted:until-reseal"), `trace: ${JSON.stringify(trace)}`); }); + // THE TRANSPORT ADDRESS, READ BEFORE THE RELOAD. Bind happens at + // boot and off the critical path, so it is polled rather than + // assumed present (solo.ts's `myEndpoint`). + const endpointBefore = await until([page], "the bound endpoint", async () => { + const id = (await solo(page, "endpointId")) as string; + return id !== "" ? id : false; + }, WAITS.converge); + // A CHECKPOINT ON PURPOSE, so the reload below is a claim about // persistence rather than a race with the worker's 500 ms debounce. // (The debounce would almost always win; "almost always" is how a @@ -207,6 +215,30 @@ const scenario: Scenario = { assert(titles.includes("call the bank"), `resumed todos: ${JSON.stringify(titles)}`); }); + await act("the iroh endpoint id is the SAME device address as before", async () => { + // THE SIXTH CLAIM, and the one the account's other devices depend + // on. In iroh the key IS the address, so before the endpoint key + // was persisted every bind minted a fresh id and a peer that had + // recorded this device's address could never reach it again — + // which made "both devices reloaded, then re-found each other" + // impossible by construction, not by bug. + // + // What makes this a real assertion rather than a tautology: the + // reload above tore the page AND the SharedWorker down, so the + // endpoint here was bound by a new worker global from a key it + // loaded out of the device namespace, through the engine's + // `device-identity.endpoint-key-pair` import. + const endpointAfter = await until([page], "the rebound endpoint", async () => { + const id = (await solo(page, "endpointId")) as string; + return id !== "" ? id : false; + }, WAITS.converge); + assertEquals( + endpointAfter, + endpointBefore, + "the device's transport address must survive a real reload", + ); + }); + await act("the reseal control asks what should unseal this device", async () => { await solo(page, "openDevice"); const sheet = await until([page], "the kept-device sheet", async () => { diff --git a/demo/e2e/scenarios/solo-resume-sync.ts b/demo/e2e/scenarios/solo-resume-sync.ts new file mode 100644 index 0000000..ceb08bd --- /dev/null +++ b/demo/e2e/scenarios/solo-resume-sync.ts @@ -0,0 +1,299 @@ +// SYNC COMES BACK BY ITSELF — after BOTH devices have been closed and +// reopened. +// +// THE ARGUMENT, and it is the last hole in the solo page's story. +// `solo-pairing` proves two independent pages can be joined into one +// account and that edits then cross. `solo-persistence` proves a device +// survives a real reload with its todo list and its transport address +// intact. Neither says anything about the state BETWEEN them, and that +// state was broken: the wiring the two pages had was made by a CEREMONY, +// out of facts the ceremony carried (the enrollment's peer ids), and a +// ceremony does not run twice. So a reloaded page wired nothing, and +// once both had reloaded the account was two devices holding the same +// document and never speaking again — with no symptom at all. Both +// pages looked healthy. Both reported an account. Nothing crossed. +// +// The silence is the point: this is the failure mode solo.ts's direction +// discipline keeps writing down, and it cannot be caught by a scenario +// that only ever reloads one page, because the other one's ceremony-time +// acceptor is still up and doing the work. +// +// WHAT MAKES IT COME BACK is the account's own device directory. Each +// `us-device` entry now carries the device's ENDPOINT ID — recorded by +// the adder from an id it observed on the wire — and an ENROLLED-BY +// naming the device that let it in. That is enough for a resumed boot to +// read its own role out of the account instead of remembering one: my +// enroller is who I dialled, so I dial it again; my children dialled me, +// so I accept again. The direction is preserved, which matters more than +// it sounds — reversed, both sides report healthy connections and +// nothing arrives (#78). +// +// FIVE CLAIMS: +// +// 1. Two independent pages pair, and converge. (Not this scenario's +// subject — its PRECONDITION. Driven through solo-util's +// `pairPages`; solo-pairing is where those beats are claims.) +// 2. BOTH pages reload, for real: two navigations, two torn-down +// SharedWorkers, two engines resumed from checkpoints. +// 3. The joiner's transport address is the SAME one as before. This is +// the premise everything else rests on — the account recorded that +// address once, and if a reload minted a fresh one the directory +// would be pointing at a device that no longer exists. Cheap to +// assert, and it fails first and legibly if the endpoint key ever +// stops being persisted. +// 4. A todo added on A AFTER the double reload reaches B — and it is +// asserted on B's RENDERED ROWS, not merely on B's engine, because +// the complaint being fixed is a user's ("my other device stopped +// updating"), not a partition's. +// 5. A profile change made on A after the double reload lands on B's +// STRIP. That is a different path over the same new wire: the us +// doc rather than the tasks partition, and the drain-poll's +// `profile-changed` → `reconcileFromDriver` → `applyProfile` apply +// chain rather than the app's own repaint. +// 6. And a MARK made on A after the double reload lands on B's strip +// too — the same us-doc path, but through the `mark-added` drain +// trigger and `applyMarks`. Cheap here (the wire is already proven +// up by 4 and 5, so this is one ceremony and one poll) and worth +// it: the mark tags are a SECOND set of drain triggers, and a +// resumed wire that carried profile events but not mark events +// would look entirely healthy. +// +// WHAT THIS SCENARIO DELIBERATELY DOES NOT CLAIM: that reloading ONLY +// the adder recovers. It does not, and the reason is an engine limit +// rather than a wiring bug — a READER has no way to learn that its peer +// went away. `conn-status` reports the outcome of the handshake and is +// never invalidated afterwards (engine/guest/src/lib.rs:3700 writes it +// once, :3706-3713 reads it back forever), and `sync-status` is one-shot +// per round rather than a subscription's health. So the un-reloaded side +// keeps a handle to a connection that no longer exists and has no +// evidence to act on. An act asserting the recovery was written, run, +// and removed when it turned out to be asserting a property nothing on +// the page can currently provide; the alternative — re-dialling on a +// timer — would be a second connection and a second set of subductions +// for the same pair, which is exactly the double-dialling solo.ts's +// direction discipline exists to prevent. solo.ts's resume section +// carries the same note next to the code. The BOTH-sides case, which is +// the ordinary one (a user closes their laptop, then their phone), is +// what this scenario claims and what the design recovers. +// +// DEADLINES ARE LONG AND DELIBERATELY SO. The post-reload claims cannot +// begin to be true until a wire that does not exist yet has been built, +// and the resume loop retries on a 5s cadence by design — it is waiting +// for a person to open a browser, not for a packet. A deadline that is +// merely "usually enough" here would be a flake generator whose failure +// text is indistinguishable from the real regression. + +import type { Page } from "npm:playwright@1.57.0"; +import type { Ctx, Scenario } from "../run.ts"; +import { + act, + assert, + assertEquals, + SOLO_KEYS, + stripMarkIcon, + stripText, + waitForBoot, +} from "../util.ts"; +import { + addTodo, + appFrame, + createAccount, + nameApp, + pairPages, + setAccountName, + solo, + stripPersonal, + todoRows, + until, + WAITS, +} from "../solo-util.ts"; + +/** The name the account carries through the pairing, and the one it is + * renamed to AFTERWARDS — the second is the claim, and it is + * deliberately unlike the first so a strip that simply never changed + * cannot pass by standing still. */ +const NAME_BEFORE = "Ada"; +const NAME_AFTER = "Ada Lovelace"; +/** Not the 265 both pages are seeded with, for the same reason. */ +const ACCOUNT_HUE = 175; +/** What A calls the app in the last act. The mark that goes with it is + * whatever the ceremony offers (a fresh random draw — see `nameApp`), + * so it is captured at ceremony time rather than named here. */ +const APP_PETNAME = "the tasks"; + +/** Everything after a double reload has to wait for a wire that is being + * rebuilt on a 5s retry cadence, behind a relay. */ +const REWIRE = 180_000; + +const scenario: Scenario = { + name: "solo-resume-sync", + why: "after BOTH devices of a paired account reload, edits and profile changes cross again without a ceremony", + page: { + path: "/solo.html", + bootGlobal: "__solo", + storage: { [SOLO_KEYS.hue]: "265" }, + }, + + async run(pageA: Page, ctx: Ctx) { + // --- the precondition: a paired account ----------------------------- + + await act("A creates an account, names it, and writes a todo", async () => { + await createAccount(pageA); + await setAccountName(pageA, NAME_BEFORE, ACCOUNT_HUE); + await addTodo(pageA, "buy milk"); + }); + + const pageB = await ctx.fresh({ + path: "/solo.html", + bootGlobal: "__solo", + storage: { [SOLO_KEYS.hue]: "265" }, + }); + + await act("B is a different device and joins A's account", async () => { + assertEquals(await solo(pageB, "hasAccount"), false, "B must hold no account of its own"); + await pairPages(pageA, pageB, "the other tab"); + await until( + [pageA, pageB], + "B's account", + async () => await solo(pageB, "hasAccount"), + WAITS.enrolled, + ); + }); + + await act("B converged: A's todo on B's rows, A's name on B's strip", async () => { + const rows = todoRows(pageB); + await rows.first().waitFor({ state: "visible", timeout: WAITS.converge }); + const titles = await until([pageA, pageB], "A's todo on B", async () => { + const t = (await solo(pageB, "todos").catch(() => [])) as string[]; + return t.includes("buy milk") ? t : false; + }, WAITS.converge); + assertEquals(titles.length, 1, `B's todos: ${JSON.stringify(titles)}`); + const personal = await until([pageA, pageB], "A's name on B's strip", async () => { + const p = await stripPersonal(pageB); + return p.identityText.includes(NAME_BEFORE) ? p : false; + }, WAITS.converge); + assert( + personal.anchorColour.includes(String(ACCOUNT_HUE)), + `B's anchor took the account's colour: ${JSON.stringify(personal)}`, + ); + }); + + // THE TRANSPORT ADDRESSES, READ BEFORE THE RELOADS. Bind happens at + // boot and off the critical path, so they are polled rather than + // assumed present (solo.ts's `myEndpoint`). + const endpointB = await until([pageA, pageB], "B's bound endpoint", async () => { + const id = (await solo(pageB, "endpointId")) as string; + return id !== "" ? id : false; + }, WAITS.converge); + + // --- the double reload ---------------------------------------------- + + await act("BOTH pages reload for real, and both come back with the account", async () => { + // CHECKPOINTS ON PURPOSE, so what follows is a claim about the + // wiring rather than a race with the worker's 500 ms debounce. + await solo(pageA, "checkpoint"); + await solo(pageB, "checkpoint"); + // TWO REAL NAVIGATIONS. Each tears its page down and the device's + // SharedWorker with it, so both engines below came back from a + // checkpoint — and, decisively, NEITHER page's ceremony-time + // acceptor survives. Reloading only one would leave the other's + // listener doing the work and the regression invisible. + await pageA.reload({ waitUntil: "domcontentloaded" }); + await waitForBoot(pageA, "__solo"); + await pageB.reload({ waitUntil: "domcontentloaded" }); + await waitForBoot(pageB, "__solo"); + + for (const [who, page] of [["A", pageA], ["B", pageB]] as [string, Page][]) { + const trace = (await solo(page, "bootTrace")) as string[]; + assert( + trace.includes("account:resumed"), + `${who} resumed onto its account rather than the first-run fork: ${JSON.stringify( + trace, + )}`, + ); + const st = await solo(page, "deviceStatus"); + assertEquals(st.resumed, true, `${who}'s engine RESUMED rather than starting fresh`); + } + // Both apps are up again from local state, without waiting for any + // peer — the wire is rebuilt in the background, which is the + // page's own rule (solo.ts: "THE APP FIRST, THE WIRE IN THE + // BACKGROUND"). + await appFrame(pageA).locator("input.new-todo").waitFor({ + state: "visible", + timeout: WAITS.boot, + }); + await appFrame(pageB).locator("input.new-todo").waitFor({ + state: "visible", + timeout: WAITS.boot, + }); + }); + + await act("B's transport address survived: the directory still points at it", async () => { + // THE PREMISE. In iroh the key IS the address, so the endpoint id + // A's account recorded for B at enrollment is only still useful if + // B rebinds to the same one. Before the endpoint key pair was + // persisted, "both devices reloaded and re-found each other" was + // impossible by construction rather than by bug. + const after = await until([pageA, pageB], "B's rebound endpoint", async () => { + const id = (await solo(pageB, "endpointId")) as string; + return id !== "" ? id : false; + }, WAITS.converge); + assertEquals(after, endpointB, "B's transport address must survive a real reload"); + }); + + // --- THE CLAIM -------------------------------------------------------- + + await act("a todo added on A after the double reload appears on B's ROWS", async () => { + await addTodo(pageA, "call the bank"); + // ON THE RENDERED ROWS, not on B's engine. The regression is a + // user's complaint — the other device stopped updating — so the + // assertion is made where the user would make it. `until` drives + // both pages' own drains while it waits, which is what keeps a + // slow machine from turning this into a flake. + const titles = await until([pageA, pageB], "A's new todo on B's rows", async () => { + const rendered = await todoRows(pageB).allTextContents(); + return rendered.some((t) => t.includes("call the bank")) ? rendered : false; + }, REWIRE); + assert( + titles.some((t) => t.includes("buy milk")), + `B kept the old row too: ${JSON.stringify(titles)}`, + ); + }); + + await act("a profile change made on A after the double reload lands on B's strip", async () => { + // THE OTHER PATH OVER THE SAME NEW WIRE: the us doc rather than the + // tasks partition, and B's drain poll rather than the app's own + // repaint. A wire that carried todos but not this would be half + // rebuilt, and the strip is where a user would see the half that + // was missing. + await setAccountName(pageA, NAME_AFTER); + const personal = await until([pageA, pageB], "A's new name on B's strip", async () => { + const p = await stripPersonal(pageB); + return p.identityText.includes(NAME_AFTER) ? p : false; + }, REWIRE); + assert( + personal.anchorColour.includes(String(ACCOUNT_HUE)), + `B's anchor is still the account's: ${JSON.stringify(personal)}`, + ); + }); + + await act("a mark made on A after the double reload lands on B's strip", async () => { + // THE OTHER FAMILY OF ACCOUNT EVENT. A names the app through the + // real ceremony; `onNamed` writes it through to the account, and B + // adopts it off `mark-added` — a different drain trigger and a + // different apply than the profile above, over the same rebuilt + // wire. B has never seen this record before, so its strip cannot + // pass by standing still. + const mark = await nameApp(pageA, APP_PETNAME); + const top = await until([pageA, pageB], "A's petname for the app on B's strip", async () => { + const t = (await stripText(pageB)).top; + return t.includes(APP_PETNAME) ? t : false; + }, REWIRE); + assert(top.includes(APP_PETNAME), `B's strip: ${JSON.stringify(top)}`); + assertEquals(await stripMarkIcon(pageB), mark, "A's mark on B's strip"); + }); + + }, +}; + +export default scenario; diff --git a/demo/e2e/solo-util.ts b/demo/e2e/solo-util.ts index 2cc76c4..beb9b5b 100644 --- a/demo/e2e/solo-util.ts +++ b/demo/e2e/solo-util.ts @@ -116,12 +116,188 @@ export async function addTodo(page: Page, title: string) { * property. */ export function stripPersonal( page: Page, -): Promise<{ anchorColour: string; identityText: string }> { +): Promise<{ anchorColour: string; identityText: string; icon: string }> { return page.evaluate(() => { const strip = document.getElementById("visor-strip"); return { anchorColour: strip?.style.getPropertyValue("--visor-bg") ?? "", identityText: (document.getElementById("visor-identity")?.textContent ?? "").trim(), + // THE USER'S OWN GLYPH, read off the button that wears it rather + // than out of the cluster's text: the glyph IS part of + // `identityText` (the button is inside the cluster), but a + // substring test there cannot tell a synced icon from a name that + // happens to contain the character, and it cannot see the + // DEFAULT — an unset record renders ⛨, which reads as "present" + // to any includes() check. The button's own textContent is the + // one honest read of "what glyph is on the bar". + icon: (document.getElementById("visor-settings")?.textContent ?? "").trim(), }; }); } + +// --- driving the two ceremonies a multi-device scenario needs ------------- +// +// WHY THESE LIVE HERE AND WHAT THEY ARE NOT. `solo-pairing` drives the +// add ceremony STEP BY STEP, in four acts, because each of those steps +// IS one of its claims: that the code is 79 characters, that the same +// six digits appear on two documents that share only a relay, that the +// grant is armed rather than instant. A scenario whose claim is about +// something AFTER pairing does not want to re-make those claims — it +// wants a paired pair — and copying thirty lines of ceremony driving +// into it would put solo-pairing's assertions somewhere they cannot be +// read as assertions. +// +// So these are DRIVERS, not claims: they assert nothing, and every +// value a caller might want to assert on comes back out. solo-pairing +// deliberately does NOT use them; its inline version is its subject. + +/** Create the account this page is the first device of, and wait for the + * app to be up — the fork's "new account" button, clicked as a user + * clicks it. */ +export async function createAccount(page: Page) { + await solo(page, "newAccount"); + await until([page], "the new account", async () => await solo(page, "hasAccount"), 60_000); + await appFrame(page).locator("input.new-todo").waitFor({ + state: "visible", + timeout: WAITS.converge, + }); +} + +/** Give the account a name (and optionally a colour) through the VISOR'S + * OWN SETTINGS SHEET, driven as a user drives it. This is the one + * direction that goes visor → account: solo.ts's `onIdentityCommitted` + * write-through carries the committed record into the account's profile. + * + * Returns once THIS page's strip shows the name — the local commit, not + * the remote arrival. What the other device does with it is the + * caller's claim. */ +export async function setAccountName(page: Page, name: string, hue?: number) { + await solo(page, "openSettings"); + await until( + [page], + "the settings sheet", + async () => await page.evaluate(() => document.getElementById("visor-settings-name") !== null), + 15_000, + ); + await page.evaluate( + ([who, h]) => { + const input = document.getElementById("visor-settings-name") as HTMLInputElement | null; + if (input) input.value = who as string; + if (h !== undefined) { + (document.querySelector( + `.settings-hues button[data-hue="${h}"]`, + ) as HTMLButtonElement | null)?.click(); + } + (document.querySelector(".settings-sheet .cred-row button:first-child") as + | HTMLButtonElement + | null)?.click(); + }, + [name, hue] as [string, number | undefined], + ); + await until( + [page], + `${name} on this page's own strip`, + async () => (await stripPersonal(page)).identityText.includes(name), + 15_000, + ); +} + +/** THE WHOLE ADD CEREMONY, end to end, between two independent pages — + * `joiner` shows a code, `adder` takes it, both read the same six + * digits, the grant arms, and the joiner confirms. + * + * The SAS pair comes back so a caller that wants to assert on it can; + * this function only waits for the two strings to be non-empty, which is + * a precondition of driving the next control rather than a claim about + * them. */ +export async function pairPages( + adder: Page, + joiner: Page, + deviceName: string, +): Promise<{ code: string; sasAdder: string; sasJoiner: string }> { + const both = [adder, joiner]; + await solo(joiner, "joinAccount"); + const code = await until([joiner], "the joiner's pairing code", async () => { + const c = (await solo(joiner, "code")) as string; + return c.length > 0 ? c : false; + }, WAITS.code); + + await solo(adder, "openAdd"); + await until([adder], "the adder's sheet", async () => await solo(adder, "addOpen"), 15_000); + if (!(await solo(adder, "pasteCode", code))) { + throw new Error("the pairing code never reached the adder's sheet"); + } + await solo(adder, "connect"); + + const sasAdder = await until(both, "the adder's SAS", async () => { + const s = ((await solo(adder, "sasAdd")) as string).trim(); + return s.length > 0 ? s : false; + }, WAITS.sas); + const sasJoiner = await until(both, "the joiner's SAS", async () => { + const s = ((await solo(joiner, "sasJoin")) as string).trim(); + return s.length > 0 ? s : false; + }, WAITS.sas); + + await solo(adder, "sasContinue"); + await until( + both, + "the grant control", + async () => (await solo(adder, "grantArmed")) !== null, + WAITS.sas, + ); + await solo(adder, "typeDeviceName", deviceName); + // ARMED, NOT INSTANT — a click before the delay elapses lands on a + // disabled button, so the driver waits exactly as a user must. + await until( + both, + "the grant to arm", + async () => (await solo(adder, "grantArmed")) === true, + 15_000, + ); + await solo(adder, "grant"); + await solo(joiner, "joinConfirm"); + return { code, sasAdder, sasJoiner }; +} + +/** NAME THE APP through the visor's REAL naming ceremony, as a user + * does: tap the strip's context cluster, type into the sheet's own + * field, pick a glyph, Save. Nothing here reaches solo.ts's `onNamed` + * directly — the only way in is the sheet's button, which is what makes + * the write-through (`onNamed` → `us-mark-put`) part of what a caller's + * claim covers. + * + * `glyph` picks a specific offer. Omitted, this takes the app's own + * NOMINATION when the sheet is offering one (flagged `data-nominated`, + * always first — see device-pairing-acts.ts, which drives the same + * controls) and otherwise the FIRST offer, whatever it is: the solo + * page deliberately does not read the app's nomination, and the rest of + * the offers are a fresh random draw per ceremony (sheets.ts's + * `iconOffers`), so no caller may name one in advance. The glyph that + * was actually clicked comes back for exactly that reason — a caller + * asserts on the mark that crossed, not on one it hoped for. */ +export async function nameApp(page: Page, petname: string, glyph?: string): Promise { + await page.click("#visor-context"); + await page.waitForSelector("#visor-drawer-inner .name-sheet input", { timeout: 15_000 }); + await page.fill("#visor-drawer-inner .name-sheet input", petname); + const picked = await page.evaluate((want: string | undefined) => { + const row = "#visor-drawer-inner .name-sheet .name-icons button"; + const b = (want !== undefined + ? document.querySelector(`${row}[data-glyph="${want}"]`) + : document.querySelector(`${row}[data-nominated="true"]`) ?? + document.querySelector(row)) as HTMLButtonElement | null; + b?.click(); + return b?.dataset.glyph ?? ""; + }, glyph); + if (picked === "") { + throw new Error( + `the naming ceremony offered no ${glyph === undefined ? "" : glyph} mark to pick`, + ); + } + await page.click("#visor-drawer-inner .name-sheet .cred-row button:first-child"); + await page.waitForFunction( + () => document.querySelector("#visor-drawer-inner .name-sheet") === null, + undefined, + { timeout: 15_000 }, + ); + return picked; +} diff --git a/demo/host/pairing-mock.ts b/demo/host/pairing-mock.ts index 4f9eba5..eeb4024 100644 --- a/demo/host/pairing-mock.ts +++ b/demo/host/pairing-mock.ts @@ -382,6 +382,12 @@ export class MockPairingNetwork { name: offer.claim.deviceName ?? "", enrolledAt: Date.now(), revoked: false, + // The mock has no transport at all, so it has no endpoint id to + // observe: "" is the record's own reading of "not recorded", and + // inventing a plausible-looking one would make the mock lie about + // a fact only a real dial can establish. + endpoint: "", + enrolledBy: "", }; doc.devices.set(agentId, dev); broadcast(doc, offer.joinInstanceId, { tag: "device-added", name: dev.name }); diff --git a/demo/host/solo.ts b/demo/host/solo.ts index 4c4a357..7242047 100644 --- a/demo/host/solo.ts +++ b/demo/host/solo.ts @@ -65,7 +65,13 @@ import { import { createRunner, type Runner } from "../../visor/surface/runner.ts"; import { createFrameBackend } from "../../visor/frame/frame-backend.ts"; import { createSurface } from "../../visor/surface/surface.ts"; -import { initVisor, type SurfaceIdentity, type Visor, VISOR_HUES } from "../../visor/ui/visor.ts"; +import { + initVisor, + type SurfaceIdentity, + type Visor, + VISOR_HUES, + VISOR_ICONS, +} from "../../visor/ui/visor.ts"; import { registerVisorSheets } from "../../visor/ui/sheets.ts"; import { type DevicePickerHost, @@ -84,7 +90,7 @@ import { usCacheKeys, visorAnnounceSink, } from "../../visor/ui/pairing.ts"; -import type { PairingDriver, UsProfile } from "../../visor/ui/pairing-driver.ts"; +import type { PairingDriver, UsMark, UsProfile } from "../../visor/ui/pairing-driver.ts"; import { createEnginePairingDriver } from "../../runtime/pairing-engine.ts"; import type { UiEvent } from "../../visor/surface/events.ts"; import { type EngineArtifacts, hex, unhex, until, type UsStorage } from "../../runtime/engine.ts"; @@ -204,6 +210,25 @@ const ENGINE_ARTIFACTS = { * is the platform's masking token, spelled once, here. */ const MASKED = { type: "password" } as const; +/** THE ACCOUNT'S USER ICON, coming the other way: UTF-8 bytes of one + * glyph (engine.wit's `us-profile.icon` — `option>`, opaque to + * the engine) turned back into a glyph this visor is willing to draw. + * + * Returns null for every "nothing to say" answer — absent, undecodable, + * or a glyph outside the visor's curated vocabulary (visor.ts's + * VISOR_ICONS; another device may run a different build) — so a caller + * can tell "the account has no icon" from "the account has one" and + * never confuse the first with "clear the one this device wears". */ +function decodeUserIcon(bytes: Uint8Array | undefined): string | null { + if (!bytes || bytes.length === 0) return null; + try { + const glyph = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return VISOR_ICONS.includes(glyph) ? glyph : null; + } catch { + return null; + } +} + async function fetchArtifacts(name: string): Promise { const [envelope, bytes] = await Promise.all([ fetch(stamp(`./${name}.plan.json`)).then((r) => { @@ -879,10 +904,21 @@ async function startApp( // WRITE-THROUGH (PAIRING.md §5): the visor has already stored and // painted; the partition is the source of truth catching up, so a // failure here is announced rather than hidden. + // + // THE GLYPH GOES WITH THE NAME, as UTF-8 bytes of the glyph + // itself (engine.wit's `us-profile.icon` is `option>` + // and the engine treats it as opaque). `rec.icon` was already + // filtered to the visor's curated vocabulary by `loadIdentity`, + // so what crosses is one vetted glyph and never free text. An + // ABSENT icon crosses as `none`, which the engine reads as + // "delete" — right, because absent here means the user's record + // genuinely has no glyph, not that this device has nothing to + // say (the settings sheet always commits a picked one). void (async () => { const res = await us.usProfileSet({ displayName: rec.name ?? "", hue: hueIndexOf(hue), + icon: rec.icon ? new TextEncoder().encode(rec.icon) : undefined, }); if (!res.ok) announce(`could not save your profile: ${res.error}`, true); })(); @@ -2379,6 +2415,44 @@ async function startApp( let usSynced = false; + /** KEEP THE ACCOUNT'S DEVICE DIRECTORY HONEST — the backstop the whole + * resume path stands on. + * + * The devices map is not just a roster any more: each entry carries + * the device's iroh ENDPOINT ID, which is the only way one device of + * an account can find another after both have been closed and + * reopened. The adder writes the joiner's endpoint at enrollment (from + * an id it observed on the wire, which is why it is trustworthy), and + * `user-create` writes the founder's own — so in the ordinary case the + * directory is already right and this call authors nothing. + * + * It is called anyway, on every boot that has both an endpoint and an + * account, because "already right" is a claim about a past that may + * have contained an older engine, a failed bind, or an entry written + * before the field existed. The engine compares before it writes + * (usdoc.rs's `device_endpoint_put`: "THE NO-OP IS THE CONTRACT"), so + * the cost of being sure is one read. + * + * IT DOES NOT CREATE A MISSING ENTRY, and that is the engine's rule + * rather than this page's choice: a device whose entry has not synced + * yet would be racing the adder's enrollment write, and the loser of + * that automerge conflict loses the name the write carried. The next + * boot finds the entry. + * + * THE RAW DRIVER: `us-device-endpoint-put` is transport bookkeeping, + * not something the visor's contract has any business carrying. */ + const recordMyEndpoint = async () => { + if (!myEndpoint) return; + try { + await enqueue(() => driver.usDeviceEndpointPut(myEndpoint as Uint8Array)); + } catch (e) { + // NOT ANNOUNCED. A directory this device could not update is a + // reachability problem for a FUTURE boot, not something the user + // can act on now, and the account still works in every other way. + console.warn(`[solo] could not record this device's endpoint: ${err(e)}`); + } + }; + /** Subscribe to `tree` with `peer`, both directions being the caller's * to arrange. `subscribe` is what makes a LATER write push rather than * wait for a poll. */ @@ -2387,6 +2461,48 @@ async function startApp( await until(`subscribed to ${what}`, () => driver.syncStatus(h), 30_000); }; + /** READER DIALS, and this is the only place on this page that opens a + * connection as the initiator. Both the ceremony's joiner and a + * RESUMED boot's reader come through here, so the direction rule above + * is stated once and obeyed twice. + * + * `usPartition` is the CEREMONY path's extra. At enrollment time the + * joiner has the account doc's id in hand (the enrollment carries it) + * and subscribes it explicitly, because nothing else will: the two + * sides have only just met. A RESUMED boot passes nothing, and that is + * not an omission — the doc's id is deliberately absent from the + * `us-*` surface, and the engine subscribes the us doc to every known + * peer itself on every pump (usdoc.rs's `ensure_subscriptions`: + * "Engine-driven because `us-*` hides doc identity by design"). By + * resume time the peer is known, so the engine does it. */ + const dialPeer = async ( + peer: Uint8Array, + peerEndpoint: Uint8Array, + usPartition?: Uint8Array, + ) => { + await enqueue(async () => { + const conn2 = await driver.irohStart(true, peerEndpoint, RELAY, peer); + await until("the other device answers", () => driver.connStatus(conn2), 30_000); + if (usPartition) await subscribe(peer, usPartition, "your account"); + }); + }; + + /** The account's todo list, as the account's own pointer map names it + * (#36) — the only channel either side has for it. + * + * THE RAW DRIVER, and for the same reason the peer ids are read raw: + * `us-partitions` is not on the visor's `PairingDriver` contract and + * must not be added to it. Which partition an app is mounted on is the + * embedder's concern; the trusted surface has no use for a partition + * id and no business holding one. */ + const awaitTasksPointer = async (what: string, timeoutMs: number) => { + const pointer = await until(what, async () => { + const list = await enqueue(() => driver.usPartitions()); + return list.find((p) => p.name === TASKS_POINTER) ?? false; + }, timeoutMs, 250); + return pointer.id; + }; + // --- role: the JOINER (this page is the new device) ---------------------- let joinWired = false; @@ -2420,24 +2536,10 @@ async function startApp( throw new Error("the enrollment carried no peer ids — cannot reach the other device"); } const peer = enrollment.peerAgentId; - await enqueue(async () => { - // READER DIALS. - const conn2 = await driver.irohStart(true, enrollment.peerEndpointId, RELAY, peer); - await until("the other device answers", () => driver.connStatus(conn2), 30_000); - await subscribe(peer, enrollment.partitionId, "your account"); - }); - // The tasks partition id has no channel but the account's own - // pointer map — which is why the map exists (#36). - // THE RAW DRIVER AGAIN, and for the same reason as the peer ids: - // `us-partitions` is not on the visor's `PairingDriver` contract - // and must not be added to it. Which partition an app is mounted - // on is the embedder's concern; the trusted surface has no use for - // a partition id and no business holding one. - const pointer = await until("your account's todo list", async () => { - const list = await enqueue(() => driver.usPartitions()); - return list.find((p) => p.name === TASKS_POINTER) ?? false; - }, 60_000, 250); - const tasksId = pointer.id; + // READER DIALS — and the account doc goes with the dial, because + // the two sides have only just met (see `dialPeer`). + await dialPeer(peer, enrollment.peerEndpointId, enrollment.partitionId); + const tasksId = await awaitTasksPointer("your account's todo list", 60_000); await enqueue(async () => { await driver.adoptPartition(tasksId); await subscribe(peer, tasksId, "your todo list"); @@ -2455,7 +2557,7 @@ async function startApp( // cache diff; on a device joining for the first time the cache is // empty, so it says nothing and the line below is the only one the // user hears. The two do not double-speak. - await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile); + await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile, applyMarks); const adopted = await us.usProfileGet(); if (adopted.ok) { // THE ADOPTION ANNOUNCEMENT (PAIRING.md §5): a remotely-caused @@ -2465,6 +2567,13 @@ async function startApp( ); } await mountApp(); + // DIRECTORY UPKEEP, at the first moment this device has an account + // to keep it in — see `recordMyEndpoint`. The adder already wrote + // this device's endpoint from the id it observed on the wire, so + // this call is almost always the engine's own no-op; it is here so + // that "every device with an endpoint and an account has said so" + // holds without a case analysis. + await recordMyEndpoint(); } catch (e) { if (joinAttempts < WIRE_ATTEMPTS) joinWired = false; else announce(`could not sync this device with your account: ${err(e)}`, true); @@ -2495,10 +2604,73 @@ async function startApp( const angle = VISOR_HUES[profile.hue] ?? VISOR_HUES[0]; visor.commitHue(angle); const rec = visor.identity(); - if (profile.displayName) visor.saveIdentity({ ...rec, name: profile.displayName }); + const next = { ...rec }; + if (profile.displayName) next.name = profile.displayName; + // THE GLYPH, decoded from the account's bytes and then VETTED. It + // was written by another device — possibly a different visor build, + // with a vocabulary this one does not have — so it passes the same + // membership test `loadIdentity` applies to hand-editable storage + // (visor.ts's VISOR_ICONS: the bidi/ZWJ/confusable firewall) before + // it can reach the one position on the strip that is supposed to be + // unspoofable. `saveIdentity` would refuse an outsider anyway; the + // check is here so a refusal does not silently DROP the glyph this + // device already wears. + // + // ABSENT OR INVALID MEANS "NOTHING TO SAY", never "clear it": an + // account that has never carried an icon must not undress a device + // whose user picked one locally. + const glyph = decodeUserIcon(profile.icon); + if (glyph) next.icon = glyph; + visor.saveIdentity(next); visor.renderIdentity(); }; + /** THE ACCOUNT'S MARKS, adopted into THIS device's trust table — the + * inbound half of `onNamed`'s write-through, at the same three moments + * `applyProfile` runs at (the join beat, a resumed boot, a mark event + * drained off the account). + * + * WHAT MAY BE ADOPTED, and why it is narrower than the list + * (PAIRING.md §5 and its repaired-view rule): `us-marks-list` returns + * the REPAIRED view, not the raw records — the engine has already run + * petname- and icon-uniqueness repair over the doc, and a record that + * LOST is handed out with `needs-reconfirm` set and, for an icon + * collision, with its icon cleared to "". Such a record is not a name + * this device may start speaking in the visor's own voice: the + * contract is that it renders NEW-with-explanation and the USER + * re-confirms it through the naming ceremony (which is also where a + * cleared icon gets re-picked, since the vocabulary is the visor's and + * the engine cannot choose a replacement). So only WHOLE marks are + * seeded — petname and icon both non-empty, `needsReconfirm` false — + * and everything else is left for the ceremony. Note that "" icon + * implies `needs-reconfirm` engine-side anyway; both are tested + * because the contract states both, not because either is redundant. + * + * DELETIONS ARE OUT OF SCOPE. There is no mark-forgotten event to + * drain, so a mark forgotten on another device stays in this device's + * table until it is forgotten here too. That is a gap, not a decision + * hidden in an omission — it needs an event before it can be closed. + * + * SILENT, like `applyProfile`: the drain has already announced the + * event, and the join beat has its own sentence. */ + const applyMarks = (marks: UsMark[]) => { + for (const m of marks) { + if (m.needsReconfirm) continue; + const petname = m.petname.trim(); + if (petname === "" || m.icon === "") continue; + // `setPetname` is the same call the ceremony makes, and it applies + // its own write-side vocabulary gate to the glyph. + sheets.marks.setPetname(m.provenance, petname, m.icon); + // AND THE LIVE SURFACE, exactly as `onNamed` refreshes it locally: + // a table seeded under a mounted app would otherwise leave the + // strip calling the surface NEW while the record says otherwise. + if (appSlot.surface && appSlot.surface.name === m.provenance) { + appSlot.surface = { ...appSlot.surface, petname, icon: m.icon, isNew: false }; + visor.renderContext(); + } + } + }; + // --- role: the ADDER (this page already has the account) ----------------- let addTicker = 0; @@ -2507,6 +2679,59 @@ async function startApp( let adderWired = false; let adderAttempts = 0; + /** WRITER ACCEPTS: no peer, no expectation — this side answers + * whoever it granted, or whoever it once granted and is now coming + * back. Idempotent, because both the add ceremony and a resumed boot + * want it up and a second listener on one endpoint would be a second + * answer to the same dial. */ + const postAcceptor = async () => { + if (acceptorPosted) return; + acceptorPosted = true; + try { + acceptorConn = await enqueue(() => + driver.irohStart(false, new Uint8Array(), RELAY, new Uint8Array()) + ); + } catch (e) { + // The flag goes back down so the next caller can try again: a + // posted-but-failed acceptor is the silent failure this whole + // section exists to avoid. + acceptorPosted = false; + throw e; + } + }; + + /** THIS PAGE'S OWN ENDPOINT DIED — which is what a `Closed` error out + * of a dial or an acceptor means, and it is sticky by design: iroh's + * home relay does not come back on its own, so every later call + * against that endpoint fails the same way. There is nothing to do but + * bind again. + * + * IT IS SAFE TO REBIND ONLY BECAUSE THE IDENTITY IS STABLE. The + * endpoint key pair is held by the device + * (engine.wit's `device-identity.endpoint-key-pair`), so a rebind + * mints the SAME endpoint id — the address the account's directory + * records, and the one the other device is dialling. Before that key + * was persisted this recovery would have silently moved this device, + * which is worse than staying broken. + * + * CONTRACT: `Closed` is matched capitalised, on the word. The + * lower-case "the peer closed the pairing stream" is a DIFFERENT + * fact — the far end going away, which is a thing to retry, not a + * reason to tear down this device's transport. */ + const isEndpointClosed = (e: unknown) => /\bClosed\b/.test(err(e)); + + const rebindEndpoint = async () => { + const id = unhex(await enqueue(() => driver.irohBind(RELAY))); + myEndpoint = id; + console.warn(`[solo] endpoint was closed; rebound the same address ${hex(id).slice(0, 8)}…`); + // The old acceptor went down with the endpoint, so the flag has to + // as well — otherwise this device would look like it was listening + // while nothing was. + acceptorPosted = false; + acceptorConn = null; + await postAcceptor(); + }; + /** Everything after the GRANT, on the adding side. * * The acceptor is posted FIRST and unconditionally: the joiner is @@ -2521,14 +2746,7 @@ async function startApp( adderWired = true; adderAttempts++; try { - if (!acceptorPosted) { - acceptorPosted = true; - // WRITER ACCEPTS: no peer, no expectation — this side answers - // whoever it granted. - acceptorConn = await enqueue(() => - driver.irohStart(false, new Uint8Array(), RELAY, new Uint8Array()) - ); - } + await postAcceptor(); const joiner = await until("the joined device", async () => { const res = await us.usDevicesList(); if (!res.ok) return false; @@ -2570,6 +2788,276 @@ async function startApp( } }; + // --- role: a RESUMED BOOT (the account is already here) ------------------ + // + // THE GAP THIS CLOSES, stated plainly: the two roles above are + // CEREMONY roles. They run once, on the edge of an enrollment, and + // everything they know — who the peer is, where to dial it — came out + // of a ceremony that is over. So a page that reloads afterwards wires + // nothing, and once BOTH devices have reloaded there is no connection + // between them at all: edits stop crossing, and neither page has any + // symptom to show for it. Silence again, which is the failure mode + // this whole file keeps writing down. + // + // WHAT REPLACES THE CEREMONY'S KNOWLEDGE is the account's own device + // directory. Each entry now carries an ENDPOINT (where that device + // can be dialled) and an ENROLLED-BY (which device let it in), so the + // enrollment tree survives in the one place both devices already + // agree about — and a resumed boot can read its own role out of it + // instead of remembering one. + // + // THE ROLE IS THE SAME ROLE, and it must be: reversing the direction + // is the healthy-looking silence #78 is about. So this side re-enacts + // exactly what it did at ceremony time — + // + // * MY ENROLLER, if I have one, is who I DIALLED then; I dial it + // again. (READER DIALS.) + // * MY CHILDREN — the devices whose `enrolled-by` is me — dialled + // ME; I ACCEPT again, and subscribe them once their dial lands. + // (WRITER ACCEPTS.) + // + // The acceptor goes up UNCONDITIONALLY and first, for the same reason + // adderWire posts it before it waits for anything: a listener that + // arrives after the dial is a dial into nothing. Every device may have + // enrolled someone, and posting it costs one call. + // + // AND IT IS PATIENT RATHER THAN PERSISTENT-UNTIL-IT-GIVES-UP. The + // ceremony's bounded three attempts are right for a ceremony: the user + // is watching, the other device is on the desk, and a failure is + // something to report while they can still act on it. Here the other + // device may be shut, on a train, or opened tomorrow — so the retry + // runs for as long as the page lives, and says so ONCE. A line per + // attempt would be a page shouting a fact that has not changed. + + // AND WHAT THIS DOES NOT RECOVER, written down because the shape of + // the gap is not obvious and the workaround for it would be worse. + // + // Only ONE side reloading is NOT recovered, when the side that + // reloaded is the one that ACCEPTS. Its own resume posts an acceptor + // and waits, correctly; but its peer — the reader — still holds a + // connection handle from before, and has no way to learn that the + // thing on the other end of it is gone. `conn-status` reports the + // outcome of the HANDSHAKE and is never invalidated afterwards + // (engine/guest/src/lib.rs:3700 writes it once; :3706-3713 reads it + // back forever), and `sync-status` is one-shot per round rather than a + // subscription's health. So the reader has no evidence of staleness at + // all, and the only "fix" available to this file would be to re-dial + // on a timer — a second connection and a second set of subductions for + // the same pair, which is precisely the double-dialling the direction + // discipline exists to prevent. + // + // The both-sides case, which is the ordinary one (a user closes their + // laptop, then their phone), IS recovered: both readers come back with + // no handle to be misled by, and dial. + // + // The honest fix belongs in the engine — a `conn-status` that goes + // false when the connection drops — and until it exists this page + // cannot tell the difference between a healthy peer and a departed + // one. + + /** Slow on purpose: the thing being waited for is another human + * opening a browser. */ + const RESUME_TICK_MS = 5_000; + /** Ten minutes, and it is a wait for a PERSON, not for a wire. */ + const RESUME_POINTER_MS = 600_000; + + let resumeWired = false; + + /** Wire a device that already holds the account. + * + * `needsTasks` is the un-dead-end: an account whose todo-list pointer + * has not reached this device yet. That used to park the page on "this + * account has no todo list yet" — a sentence that describes a + * PERMANENT state and was being said about a temporary one. The + * pointer is us-doc content, so it arrives exactly when the wire below + * comes up; the page waits for it instead of concluding from it. */ + const resumeWire = async (needsTasks: boolean) => { + if (resumeWired) return; + resumeWired = true; + + const st = await conn.status(); + // Hex both ways, lower-cased at the boundary: the worker's `meta` + // copy and the directory's keys are both written by `hex()` + // (runtime/engine.ts), so this is belt-and-braces rather than a + // known mismatch — and a silent no-match here would be the page + // deciding it has no role in its own account. + const me = (st.agentId ?? "").toLowerCase(); + if (!me) { + console.warn("[solo] resume wiring: this device has no recorded agent id"); + return; + } + + let announced = false; + const sayWaiting = () => { + if (announced) return; + announced = true; + status("waiting for your other device…"); + }; + + /** Dialled at most once — a second dial to the same peer is a second + * connection carrying the same subscriptions. */ + let dialled = false; + /** Which peers this device has already subscribed the todo list to. + * Keyed by agent id, hex. */ + const tasksWired = new Set(); + /** Whether the tasks partition is this device's to read. False only + * on the `needsTasks` path, until the pointer arrives and the + * adoption below runs. */ + let tasksHeld = !needsTasks; + + /** Subscribe the account's todo list to one peer. + * + * ONLY THE TASKS PARTITION, because only it needs asking: the engine + * subscribes the us doc to every known peer itself on every pump + * (usdoc.rs's `ensure_subscriptions`), and it has no name for this + * one. Same division of labour as adderWire's. */ + const wireTasks = async (peerHex: string) => { + if (!tasksHeld || tasksWired.has(peerHex)) return; + const list = await enqueue(() => driver.usPartitions()); + const part = list.find((p) => p.name === TASKS_POINTER); + if (!part) return; + tasksWired.add(peerHex); + try { + await enqueue(() => subscribe(unhex(peerHex), part.id, "your todo list")); + usSynced = true; + console.log(`[solo] subduction re-wired: this device ⇄ ${peerHex.slice(0, 8)}…`); + } catch (e) { + // Back out of the set: a subscription that did not take must be + // retried, or this device is silently unsubscribed for ever. + tasksWired.delete(peerHex); + throw e; + } + }; + + // THE POINTER ARM, when this device has no todo list yet. It runs + // alongside the retry loop rather than inside it: the loop's job is + // the wire, and this is what the wire is FOR. + if (needsTasks) { + sayWaiting(); + say("waiting for your other device…"); + void (async () => { + try { + const tasksId = await awaitTasksPointer( + "your account's todo list", + RESUME_POINTER_MS, + ); + // CONTRACT: `adopt-partition` REPLACES whatever this device + // held for that id with an empty document + // (engine/guest/src/lib.rs:3804), so it is called only on the + // path where this device demonstrably held nothing — the + // pointer was absent at boot and has only just arrived. A + // resumed device that already had the partition gets it back + // from its checkpoint and must never be re-adopted. + await enqueue(() => driver.adoptPartition(tasksId)); + tasksHeld = true; + await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile, applyMarks); + await mountApp(); + } catch (e) { + announce(`could not open your account's todo list: ${err(e)}`, true); + console.warn(`[solo] resume: the tasks pointer never arrived: ${err(e)}`); + } + })(); + } + + /** One attempt at everything, run again every `RESUME_TICK_MS`. Each + * step guards itself, so a tick that achieves half the wiring keeps + * the half it got. */ + const tick = async () => { + // AT MOST ONE REBIND PER TICK. `Closed` is sticky, so an + // unguarded rebind-on-error would be a rebind storm: every failing + // call in this function would mint a fresh endpoint for the next + // one to fail against. + let rebound = false; + const onError = async (e: unknown, what: string) => { + console.warn(`[solo] resume ${what}: ${err(e)}`); + if (rebound || !isEndpointClosed(e)) return; + rebound = true; + try { + await rebindEndpoint(); + } catch (e2) { + console.warn(`[solo] resume: rebinding the endpoint failed: ${err(e2)}`); + } + }; + + // WRITER ACCEPTS, first and always — see the section note. + try { + await postAcceptor(); + } catch (e) { + await onError(e, "acceptor"); + } + + const res = await us.usDevicesList(); + if (!res.ok) return; + const devices = res.value; + const mine = devices.find((d) => d.agentId.toLowerCase() === me); + + // READER DIALS: my enroller is the device I dialled at ceremony + // time, and an empty `enrolled-by` means I am the founding device + // and never dialled anyone. + if (!dialled && mine && mine.enrolledBy !== "") { + const enroller = devices.find( + (d) => d.agentId.toLowerCase() === mine.enrolledBy.toLowerCase(), + ); + // An entry with no endpoint is the directory saying "not + // observed" (engine.wit's `us-device`), and there is nothing + // honest to dial with. It may be filled in by that device's own + // boot-time upkeep, so this keeps looking rather than failing. + if (enroller && !enroller.revoked && enroller.endpoint !== "") { + sayWaiting(); + try { + await dialPeer(unhex(enroller.agentId), unhex(enroller.endpoint)); + dialled = true; + } catch (e) { + await onError(e, "dial"); + } + } + } + if (dialled && mine) { + try { + await wireTasks(mine.enrolledBy.toLowerCase()); + } catch (e) { + await onError(e, "subscribe (enroller)"); + } + } + + // MY CHILDREN dialled me, so this side waits for the dial to LAND + // before subscribing. adderWire's reasoning applies unchanged: a + // `sync-start` against a peer this side has no connection to + // reports a healthy handle and delivers nothing — the same silent + // shape as the reversed-direction bug (#78). The difference from + // adderWire is only that this is a POLL rather than an `until`: + // the tick is already the waiting loop. + const children = devices.filter( + (d) => !d.revoked && d.enrolledBy !== "" && d.enrolledBy.toLowerCase() === me, + ); + if (children.length > 0 && acceptorConn !== null) { + sayWaiting(); + let connected = false; + try { + connected = Boolean(await driver.connStatus(acceptorConn)); + } catch (e) { + await onError(e, "acceptor status"); + } + if (connected) { + for (const child of children) { + try { + await wireTasks(child.agentId.toLowerCase()); + } catch (e) { + await onError(e, "subscribe (child)"); + } + } + } + } + }; + + // NOT `until`, and not a bounded count. `poll` skips a tick whose + // predecessor is still running, which is exactly right here: a dial + // has its own 30s deadline inside it, and overlapping attempts would + // be several endpoints racing to reach one peer. + void tick(); + poll(RESUME_TICK_MS, tick); + }; + const addTenant = visor.drawer.tenant<{ container: HTMLElement }>({ name: "add-device", exclusive: true, @@ -2704,17 +3192,30 @@ async function startApp( // announces any diff it finds, which is the announcement this moment // owes; there is no adoption fanfare on a device that already // belongs to the account. - await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile); + await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile, applyMarks); + // THE DIRECTORY, BEFORE ANYTHING WAITS ON IT. This device's own + // entry is what the account's OTHER devices dial, so it is refreshed + // at the top of the resumed boot rather than after the wiring that + // depends on the symmetric fact being true over there. + await recordMyEndpoint(); const parts = await enqueue(() => driver.usPartitions()); const tasksPart = parts.find((p) => p.name === TASKS_POINTER); if (tasksPart) { + // THE APP FIRST, THE WIRE IN THE BACKGROUND. Everything this + // device needs to show the user is already on disk; making them + // watch a spinner while a peer that may be switched off is dialled + // would be the page confusing "not yet in sync" with "not yet + // usable". await mountApp(); + void resumeWire(false); } else { - // An account with no todo list is not a first run — offering to - // create a SECOND account here would be the page guessing at a - // state it does not understand. - status("this account has no todo list yet"); - say("ready — no todo list on this account"); + // NOT A DEAD END ANY MORE. An account with no todo list is still + // not a first run — offering to create a SECOND account here would + // be the page guessing at a state it does not understand — but nor + // is it a state to park in. The pointer lives in the account's own + // document, so it arrives when the wire does; `resumeWire` says so + // on screen, waits, adopts, and mounts. + void resumeWire(true); } } else { note("account:none"); @@ -2739,12 +3240,35 @@ async function startApp( // or colour moved under this device, so the strip is repainted from // the account rather than left stale behind a sentence describing a // change the user cannot see. - poll(1000, async () => { + // + // ONE FUNCTION, TWO CALLERS, and that is not tidiness. `usEvents` is a + // DRAIN: it hands each event out exactly once, so whoever calls it + // owns what the event meant. The driving hook below used to call it on + // its own and then throw the batch away — which meant a scenario + // ticking the page faster than this timer could swallow the one + // `profile-changed` the timer existed to act on, and the strip would + // stay stale for a reason nothing on the page could report. Any future + // caller of the drain must come through here. + const drainAndAdopt = async () => { const events = await drainAnnouncements(us, announce); - if (events.some((ev) => ev.tag === "profile-changed")) { - await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile); + // THE ADOPTION HALF, for both families of remotely-caused change the + // account can hand this device. `profile-changed` moves the strip's + // identity; the three MARK tags move the trust table — including + // `mark-conflict-repaired`, which is the one that most needs + // adopting rather than merely announcing, since after a repair the + // account's view of a record and this device's may genuinely differ. + // One reconcile covers all of them: it re-reads both halves anyway, + // and a batch mentioning several is still one round trip. + if ( + events.some((ev) => + ev.tag === "profile-changed" || ev.tag === "mark-added" || + ev.tag === "mark-changed" || ev.tag === "mark-conflict-repaired" + ) + ) { + await reconcileFromDriver(us, US_CACHE_KEYS, announce, applyProfile, applyMarks); } - }); + }; + poll(1000, drainAndAdopt); // --- driving hooks -------------------------------------------------------- // @@ -2759,6 +3283,13 @@ async function startApp( /** THE DEVICE, as the store holds it. Nothing personal: an opaque * id, the tier, the policy and the rungs the picker reasons about. */ deviceId: () => conn.deviceId, + /** THE TRANSPORT ADDRESS this page bound, hex, or "" when the bind + * failed. Read-only and deliberately narrow: it exists so a scenario + * can assert that the id is the SAME one across a real reload — + * which is the whole claim of the persisted endpoint key + * (engine.wit's `device-identity.endpoint-key-pair`). Nothing here + * lets a test set it. */ + endpointId: () => (myEndpoint ? hex(myEndpoint) : ""), deviceStatus: () => conn.status(), /** The strip's device line — "" when the rule says there is none. */ deviceLabel: () => @@ -2920,10 +3451,12 @@ async function startApp( }); return res.ok; }, - /** Drain both timers once, without waiting on them. */ + /** Drain both timers once, without waiting on them. The drain goes + * through `drainAndAdopt` for the reason written there: an event + * handed out once must be ACTED on once, by whoever took it. */ tick: async () => { if (await entry?.joinHandle.tick()) void joinerWire(); - await drainAnnouncements(us, announce); + await drainAndAdopt(); }, appRunner: () => appRunner !== null, /** The storage sheet, entered the way a user enters it. */ diff --git a/engine/guest/src/lib.rs b/engine/guest/src/lib.rs index 31473b3..d522cb5 100644 --- a/engine/guest/src/lib.rs +++ b/engine/guest/src/lib.rs @@ -126,6 +126,7 @@ use exports::polyvisor::engine::driver::{ }; use exports::polyvisor::tasks::tasks::{Guest as TasksGuest, Snapshot, TodoItem}; use polymorph::iroh::endpoint::{Endpoint, EndpointOptions, RecvStream, SendStream}; +use polymorph::iroh::identity_from_keys; use polymorph::iroh::identity_generate; use polymorph::iroh::types::{EndpointAddr, TransportAddr}; @@ -2380,6 +2381,18 @@ fn own_agent_id() -> Result, String> { with_state(|s| s.my_peer.as_bytes().to_vec()) } +/// This device's own iroh endpoint id, or EMPTY when it has not bound +/// one yet. +/// +/// Empty is not an error and callers must not treat it as one: `iroh-bind` +/// is the host's call to make and the ordering against `user-create` is +/// the host's business. The devices-map entry simply carries no endpoint +/// until the boot-time `us-device-endpoint-put` fills it in — which is +/// the path that keeps it current anyway. +fn own_endpoint_id() -> Result, String> { + with_state(|s| s.endpoint.as_ref().map(|e| e.id()).unwrap_or_default()) +} + /// Refresh the bridge's event cache and offer everything to every peer. async fn flush_keyhive() -> Result<(), String> { let proto = with_state(|s| s.proto.clone())?; @@ -3495,9 +3508,32 @@ impl DriverGuest for Component { } async fn iroh_bind(relay_url: String) -> Result { - let identity = identity_generate::generate() - .await - .map_err(|e| format!("identity-generate: {e:?}"))?; + // THE ENDPOINT IDENTITY IS THE ADDRESS (engine.wit's + // `device-identity.endpoint-key-pair`). Ask the embedder first: + // an embedding that persists a transport key gets the SAME + // endpoint id back on every bind, which is what lets two devices + // that have both been closed and reopened still dial each other. + // `none` — the default an embedder gets for free — mints a fresh + // identity, exactly as this function always did. + let identity = match polyvisor::engine::device_identity::endpoint_key_pair().await { + Some((signing, verifying)) => { + // Handed on UNWRAPPED, unlike `embedder_device_key`'s + // pair: both this import and `identity-from-keys` speak + // `polymorph:webcrypto/signature`, so these are already + // the resources the iroh component wants. The pair is + // validated THERE — Ed25519, `sign` permitted, halves + // checked against each other by a sign/verify probe + // (iroh.wit's `identity-from-keys.from-keys`) — so a + // mis-provisioned pair fails here at bind rather than as + // a handshake failure against every peer. + identity_from_keys::from_keys(signing, verifying) + .await + .map_err(|e| format!("identity-from-keys: {e:?}"))? + } + None => identity_generate::generate() + .await + .map_err(|e| format!("identity-generate: {e:?}"))?, + }; let options = EndpointOptions::new(&identity); options.add_alpn(ALPN); // Pairing runs on its own ALPN, so a pairing dial can never be @@ -3902,6 +3938,10 @@ impl DriverGuest for Component { usdoc::device_revoke(agent_id).await } + async fn us_device_endpoint_put(endpoint: Vec) -> Result<(), String> { + usdoc::device_endpoint_put(endpoint).await + } + async fn us_events() -> Result, String> { usdoc::events().await } diff --git a/engine/guest/src/pairing.rs b/engine/guest/src/pairing.rs index da387d6..ecdef00 100644 --- a/engine/guest/src/pairing.rs +++ b/engine/guest/src/pairing.rs @@ -918,8 +918,13 @@ async fn add_session( // 6. Enrollment writes, in the order §2 pins: group membership at // admin FIRST, so the card exported next carries the delegation. + // + // `join_ep` rides along into the devices entry: it is the endpoint + // this side DIALED to get here, so the ceremony that just completed + // is itself the proof that the id belongs to the joiner (iroh's + // key-is-address). Nothing else about the ceremony changes. let (user_group_id, group_card, partition_id) = - crate::usdoc::enroll_device(&joiner, &device_name).await?; + crate::usdoc::enroll_device(&joiner, &device_name, &join_ep).await?; send_msg( &out_tx, &PairMsg::Enroll { diff --git a/engine/guest/src/usdoc.rs b/engine/guest/src/usdoc.rs index 2de4a32..fb8d8b9 100644 --- a/engine/guest/src/usdoc.rs +++ b/engine/guest/src/usdoc.rs @@ -214,7 +214,23 @@ fn read_profile(am: &AutoCommit) -> (String, u16, Option>) { ) } -fn read_devices(am: &AutoCommit) -> BTreeMap { +/// One entry of the devices map, as the document holds it. +/// +/// A named record rather than a tuple because it grew past the point +/// where positional reads stayed honest — and because two of its fields +/// are ADDITIVE (engine.wit's `us-device`): `endpoint` and `enrolled_by` +/// are empty on every entry written before those keys existed, and the +/// read path must treat that as ordinary rather than as damage. +#[derive(Clone, PartialEq)] +struct DeviceRow { + name: String, + enrolled_at: u64, + revoked: bool, + endpoint: Vec, + enrolled_by: Vec, +} + +fn read_devices(am: &AutoCommit) -> BTreeMap { let mut out = BTreeMap::new(); let Some(devices) = map_at(am, DEVICES) else { return out; @@ -225,11 +241,16 @@ fn read_devices(am: &AutoCommit) -> BTreeMap { }; out.insert( key.to_string(), - ( - get_str(am, &d, "name").unwrap_or_default(), - get_u64(am, &d, "enrolled-at").unwrap_or(0), - get_bool(am, &d, "revoked"), - ), + DeviceRow { + name: get_str(am, &d, "name").unwrap_or_default(), + enrolled_at: get_u64(am, &d, "enrolled-at").unwrap_or(0), + revoked: get_bool(am, &d, "revoked"), + // ADDITIVE (engine.wit's `us-device`): an entry written + // before these keys existed reads back empty, which is + // the documented value for "not recorded", not an error. + endpoint: get_bytes(am, &d, "endpoint").unwrap_or_default(), + enrolled_by: get_bytes(am, &d, "enrolled-by").unwrap_or_default(), + }, ); } out @@ -478,7 +499,7 @@ struct Snap { /// what keeps a repair write from announcing itself twice. marks: BTreeMap, u64, bool)>, repairs: BTreeSet<(String, String)>, - devices: BTreeMap, + devices: BTreeMap, /// The account's storage record. `None` until an account binds one; /// compared WHOLE, so a change of any field (not just of provider) /// is a change worth announcing. @@ -531,11 +552,15 @@ fn diff(pre: &Snap, post: &Snap) -> Vec { for (prov, kind) in new_repairs { out.push(UsEvent::MarkConflictRepaired((prov.clone(), kind.clone()))); } - for (id, (name, _, revoked)) in &post.devices { + // Devices announce APPEARANCE and REVOCATION, and nothing else: an + // endpoint that moved is not news to a user, and every device + // rewrites its own endpoint at boot, so an event on that field would + // be a notification storm about plumbing. + for (id, row) in &post.devices { match pre.devices.get(id) { - None => out.push(UsEvent::DeviceAdded(name.clone())), - Some((_, _, was_revoked)) if !*was_revoked && *revoked => { - out.push(UsEvent::DeviceRevoked(name.clone())) + None => out.push(UsEvent::DeviceAdded(row.name.clone())), + Some(before) if !before.revoked && row.revoked => { + out.push(UsEvent::DeviceRevoked(row.name.clone())) } Some(_) => {} } @@ -786,7 +811,13 @@ pub(crate) async fn create(profile: UsProfile) -> Result, String> { // with an empty name keeps `us-devices-list` complete (a missing // first device would be worse than an unnamed one) and leaves the // naming to the visor. Flagged to the dispatcher. - device_entry(&crate::own_agent_id()?, "").await?; + // The founding device: `enrolled-by` is EMPTY because nobody + // enrolled it, which is the record's documented reading of empty + // rather than a gap. Its endpoint is recorded if the transport + // happens to be bound already — the host's ordering, not ours — and + // the boot-time `us_device_endpoint_put` covers the case where it is + // not. + device_entry(&crate::own_agent_id()?, "", &crate::own_endpoint_id()?, &[]).await?; set_baseline()?; Ok(group) } @@ -881,9 +912,16 @@ async fn anchor_data_partitions(agent: &[u8]) -> Result<(), String> { } /// The adder's enrollment writes, in the order PAIRING.md §2 pins. +/// +/// `join_ep` is the joiner's iroh endpoint id AS THIS DEVICE OBSERVED +/// IT: the adder dialed that endpoint to run the ceremony, and in iroh +/// the key is the address, so the connection itself authenticates the +/// id. Recording it here is what lets the account re-find the new device +/// after both sides have been closed and reopened. pub(crate) async fn enroll_device( joiner: &[u8], name: &str, + join_ep: &[u8], ) -> Result<(Vec, Vec, Vec), String> { let group = with_state(|s| s.us.user_group.clone())? .ok_or("no user group on this device (user-create first)")?; @@ -922,7 +960,7 @@ pub(crate) async fn enroll_device( // ANCHOR: it is sealed under an epoch the joiner holds, so it is // guaranteed to be a chunk the joiner can open directly — and from a // chunk it can open, the whole ancestry is reachable (§2, §4b). - device_entry(joiner, name).await?; + device_entry(joiner, name, join_ep, &crate::own_agent_id()?).await?; // 6. The same anchor, for the account's DATA partitions — the // devices entry only covers this document (see // `anchor_data_partitions`). AFTER the rotation in step 2, so the @@ -933,10 +971,28 @@ pub(crate) async fn enroll_device( Ok((group, card, partition)) } -async fn device_entry(agent: &[u8], name: &str) -> Result<(), String> { +/// Write (or refresh) one device's entry. +/// +/// `endpoint` and `enrolled_by` are written ONLY when non-empty: an +/// empty argument means "nothing to say about this", and writing an +/// empty value would turn a silence into an assertion — a later, better +/// informed write (the joiner's own boot-time +/// `us_device_endpoint_put`) would then be overwriting a real key with +/// nothing on the losing side of a merge. +async fn device_entry( + agent: &[u8], + name: &str, + endpoint: &[u8], + enrolled_by: &[u8], +) -> Result<(), String> { let key = hex::encode(agent); let enrolled_at = crate::now_ms_u64(); let name = name.to_string(); + // Raw bytes, as `us-partition`'s id and the profile icon are stored: + // the map KEY is hex because automerge map keys are strings, the + // values are not. + let endpoint = endpoint.to_vec(); + let enrolled_by = enrolled_by.to_vec(); write(move |am| { let devices = match map_at(am, DEVICES) { Some(d) => d, @@ -956,6 +1012,14 @@ async fn device_entry(agent: &[u8], name: &str) -> Result<(), String> { .map_err(|e| format!("enrolled-at: {e}"))?; am.put(&d, "revoked", false) .map_err(|e| format!("revoked: {e}"))?; + if !endpoint.is_empty() { + am.put(&d, "endpoint", endpoint) + .map_err(|e| format!("device endpoint: {e}"))?; + } + if !enrolled_by.is_empty() { + am.put(&d, "enrolled-by", enrolled_by) + .map_err(|e| format!("enrolled-by: {e}"))?; + } Ok(()) }) .await @@ -1202,13 +1266,15 @@ pub(crate) async fn devices_list() -> Result, String> { pump().await?; let devices = read_us(read_devices)?; let mut out = Vec::new(); - for (key, (name, enrolled_at, revoked)) in devices { + for (key, row) in devices { let Ok(raw) = hex::decode(&key) else { continue }; out.push(UsDevice { agent_id: raw, - name, - enrolled_at, - revoked, + name: row.name, + enrolled_at: row.enrolled_at, + revoked: row.revoked, + endpoint: row.endpoint, + enrolled_by: row.enrolled_by, }); } out.sort_by(|a, b| { @@ -1219,6 +1285,47 @@ pub(crate) async fn devices_list() -> Result, String> { Ok(out) } +/// Record THIS device's own endpoint id (engine.wit's +/// `us-device-endpoint-put`). +/// +/// THE NO-OP IS THE CONTRACT. The host calls this on every boot, and a +/// write that authored a chunk every time would grow the one document +/// every device syncs by one change per page load per device, forever, +/// carrying nothing new. So the stored value is read and compared FIRST, +/// and an equal value returns without touching the document at all. +/// +/// CONTRACT: an ABSENT entry is left absent rather than created. The +/// devices map is keyed by agent id, and two devices concurrently +/// `put_object`-ing a fresh map under the same key is an automerge +/// conflict whose loser's fields vanish — so a device whose entry has +/// not synced yet would be racing the adder's enrollment write and could +/// silently drop the name that write carried. It costs nothing to wait: +/// the adder already recorded this device's endpoint at enrollment (from +/// an id it observed on the wire), and the next boot finds the entry. +pub(crate) async fn device_endpoint_put(endpoint: Vec) -> Result<(), String> { + if endpoint.is_empty() { + return Err("a device endpoint must not be empty".into()); + } + pump().await?; + let key = hex::encode(crate::own_agent_id()?); + let stored = read_us(|am| read_devices(am).get(&key).map(|r| r.endpoint.clone()))?; + match stored { + // Already ours, byte for byte: nothing to author. + Some(ref e) if *e == endpoint => return Ok(()), + // No entry for this device yet — see the CONTRACT note above. + None => return Ok(()), + Some(_) => {} + } + write(move |am| { + let devices = map_at(am, DEVICES).ok_or("no devices map")?; + let d = child_map(am, &devices, &key).ok_or("no entry for this device")?; + am.put(&d, "endpoint", endpoint) + .map_err(|e| format!("device endpoint: {e}"))?; + Ok(()) + }) + .await +} + pub(crate) async fn device_revoke(agent_id: Vec) -> Result<(), String> { let _ = arr32(&agent_id, "agent id")?; let group = with_state(|s| s.us.user_group.clone())? diff --git a/engine/guest/wit/engine.wit b/engine/guest/wit/engine.wit index d5c04cb..2a1f3a5 100644 --- a/engine/guest/wit/engine.wit +++ b/engine/guest/wit/engine.wit @@ -434,6 +434,26 @@ interface driver { name: string, enrolled-at: u64, revoked: bool, + /// THE DEVICE'S IROH ENDPOINT ID — how the account's other + /// devices dial this one after everybody has been closed and + /// reopened. Empty when this device has not recorded one yet. + /// + /// ADDITIVE: entries written before this key existed read back + /// EMPTY, never as an error. A document is not migrated; it + /// simply fills in, because every device upserts its own entry + /// at boot (`us-device-endpoint-put`). + /// + /// Two writers, and the difference is worth keeping straight. + /// The ADDER writes the joiner's endpoint at enrollment from an + /// id it OBSERVED on a connection it dialed — key-is-address, so + /// the observation is transport-authenticated. Every device also + /// writes its OWN entry at boot, which is the path that keeps + /// this current when a transport identity changes. + endpoint: list, + /// The agent id of the device that enrolled this one. Empty for + /// the FOUNDING device, which nobody enrolled — and, ADDITIVELY, + /// for every entry written before this key existed. + enrolled-by: list, } variant us-event { @@ -582,6 +602,24 @@ interface driver { us-devices-list: async func() -> result, string>; us-device-revoke: async func(agent-id: list) -> result<_, string>; + /// Record THIS device's own iroh endpoint id in the account's device + /// map, so the account's other devices can find it again after a + /// reload. Requires an existing user system — same error convention + /// as `us-profile-set` when there is none. + /// + /// CALLED EVERY BOOT, which is what pins the second half of the + /// contract: when the stored value already EQUALS `endpoint` this is + /// a no-op and authors NO chunk. A boot-time write that produced a + /// change every time would add one chunk per page load per device to + /// a document every device syncs, forever, for information that did + /// not change. + /// + /// Only ever this device's own entry: an endpoint is a claim about + /// where a device is, and the only unimpeachable source for that + /// claim is the device itself — or a peer that dialed it (the adder + /// at enrollment, see `us-device.endpoint`). + us-device-endpoint-put: async func(endpoint: list) -> result<_, string>; + /// Drain remotely-caused changes the visor must announce (#22). /// Local-echo suppression is engine-side: a device never receives /// events for its own writes. @@ -635,6 +673,36 @@ interface device-identity { /// this import with a stub answering `none` is byte-for-byte the /// engine it was before this interface existed. device-key-pair: async func() -> option>; + + /// The embedder-held TRANSPORT identity: the iroh endpoint key. + /// + /// Consulted at `iroh-bind`. `some(pair)` binds the endpoint under + /// that key through `polymorph:iroh/identity-from-keys`; `none` + /// mints a fresh one through `identity-generate`, which is exactly + /// the behavior of every embedding before this function existed. + /// + /// WHY IT MATTERS THAT IT PERSISTS. In iroh the key IS the address: + /// an endpoint id is the public half, and peers dial it. A freshly + /// minted key per bind therefore means a device's address dies with + /// its page session, and a peer that recorded the old one can never + /// reach it again. Persisting the key is what makes "both devices + /// reloaded, then re-found each other" possible at all. + /// + /// A SECOND PAIR, DELIBERATELY — not the signing identity above. + /// Two reasons, and either alone would be enough. (1) No + /// cross-protocol key reuse: the signing key authors keyhive + /// delegations and signs subduction messages, and iroh's handshake + /// signs its own transcripts; one key over two protocols' message + /// spaces is the classic way a signature minted for one becomes + /// meaningful in the other. (2) Separability: the transport identity + /// is WHERE a device is and the signing identity is WHO it is, and + /// an embedder must be able to rotate one without repudiating the + /// account built on the other. + /// + /// Same pair convention and the same free default as + /// `device-key-pair`: a host that answers `none` here is + /// byte-for-byte the engine it was before this function existed. + endpoint-key-pair: async func() -> option>; } /// The shared shape of the storage egress imports below. Named as its @@ -651,6 +719,10 @@ interface store-fetch-types { world engine { import polymorph:iroh/endpoint@0.1.0; import polymorph:iroh/identity-generate@0.1.0; + /// The other half of the endpoint-identity story: minting an iroh + /// identity from the embedder's persisted key handles, for when + /// `device-identity.endpoint-key-pair` answers `some`. + import polymorph:iroh/identity-from-keys@0.1.0; /// The embedder-held device identity (see the interface above). The /// world import MUST be filled at instantiation, so every embedder diff --git a/engine/host/src/main.rs b/engine/host/src/main.rs index 1921526..72b3884 100644 --- a/engine/host/src/main.rs +++ b/engine/host/src/main.rs @@ -398,6 +398,23 @@ impl bindings::polyvisor::engine::device_identity::HostWithStore for Ctx { > { Ok(None) } + + /// The TRANSPORT identity, and `none` for the same reason as above: + /// this host cannot construct `signature` resources from Rust-held + /// material. The guest's `none` branch mints a fresh iroh identity + /// per bind — which is what this host has always done, so nothing + /// native regresses; what it cannot exercise is the STABLE endpoint + /// id across binds, and that lives in the browser act battery. + async fn endpoint_key_pair( + _accessor: &Accessor, + ) -> Result< + Option<( + wasmtime::component::Resource, + wasmtime::component::Resource, + )>, + > { + Ok(None) + } } /// The sync half of the same interface: empty (every function here is diff --git a/runtime/device-store/identity-keys.ts b/runtime/device-store/identity-keys.ts index 9537048..b9545f6 100644 --- a/runtime/device-store/identity-keys.ts +++ b/runtime/device-store/identity-keys.ts @@ -51,6 +51,25 @@ const STORE = "identity"; */ export const DEVICE_IDENTITY_KEY = "device-signing"; +/** + * THE DEVICE'S IROH ENDPOINT IDENTITY, by name — the transport key, + * kept beside the signing one and deliberately NOT the same key. + * + * In iroh the key is the address: the endpoint id peers dial is this + * pair's public half. Persisting it is what makes a device re-findable + * after a reload; before it existed the engine minted a fresh identity + * on every bind, so an id a peer had recorded went dead with the page. + * + * WHY A SECOND PAIR AND NOT A REUSE of the signing key (engine.wit's + * `device-identity.endpoint-key-pair`, where the ruling is written + * out): no cross-protocol key reuse between keyhive's signatures and + * iroh's handshake, and the transport identity — WHERE the device is — + * stays rotatable without repudiating the account identity that says + * WHO it is. Same store, same validate-on-load discipline, same + * algorithm; a different id, which is the whole difference. + */ +export const DEVICE_ENDPOINT_KEY = "device-endpoint"; + /** * Ed25519, and only Ed25519. The engine's device identity is an Ed25519 * signing key; keeping the algorithm a CONSTANT rather than a stored diff --git a/runtime/device-store/mod.ts b/runtime/device-store/mod.ts index 0437747..57cef92 100644 --- a/runtime/device-store/mod.ts +++ b/runtime/device-store/mod.ts @@ -64,6 +64,7 @@ export { export { deleteIdentity, + DEVICE_ENDPOINT_KEY, DEVICE_IDENTITY_KEY, IdentityKeyError, loadIdentity, diff --git a/runtime/device-store/rpc.ts b/runtime/device-store/rpc.ts index 0368ec5..89ee63e 100644 --- a/runtime/device-store/rpc.ts +++ b/runtime/device-store/rpc.ts @@ -285,6 +285,7 @@ export const DRIVER_METHODS = [ "usContactPut", "usDevicesList", "usDeviceRevoke", + "usDeviceEndpointPut", "usEvents", "stats", ] as const; diff --git a/runtime/device-store/worker.ts b/runtime/device-store/worker.ts index 967e9ca..bd33fb5 100644 --- a/runtime/device-store/worker.ts +++ b/runtime/device-store/worker.ts @@ -87,7 +87,7 @@ import { getSigningKey, makeSigner, type Signer } from "../keystore.ts"; // existing. import { SigningKey, VerifyingKey } from "@polymorph/webcrypto-polyengine"; import { getDevice } from "./index.ts"; -import { DEVICE_IDENTITY_KEY, loadOrMintIdentity } from "./identity-keys.ts"; +import { DEVICE_ENDPOINT_KEY, DEVICE_IDENTITY_KEY, loadOrMintIdentity } from "./identity-keys.ts"; import { type DeviceNamespace, destroyNamespace, openNamespace } from "./namespace.ts"; import { createSealedDek, @@ -1452,6 +1452,15 @@ async function fetchArtifacts(spec: AttachSpec["artifacts"]) { */ let identityPair: Promise | undefined; +/** + * The device's TRANSPORT key pair, cached on the same terms and for the + * same reasons as the signing one above — and a genuinely separate pair + * (identity-keys.ts's `DEVICE_ENDPOINT_KEY`, engine.wit's + * `endpoint-key-pair`): iroh's endpoint id is this key's public half, + * and no key crosses between keyhive's signatures and iroh's handshake. + */ +let endpointPair: Promise | undefined; + /** Where the fresh-init agent id is recorded, in the unsealed `meta` * store beside the lease and the boot counter. */ const AGENT_KEY = "agent"; @@ -1466,6 +1475,16 @@ function devicePair(): Promise { return identityPair; } +function endpointKey(): Promise { + endpointPair ??= loadOrMintIdentity(ns, DEVICE_ENDPOINT_KEY) + .then((r) => r.pair) + .catch((e) => { + endpointPair = undefined; + throw e; + }); + return endpointPair; +} + /** * Build the `device-identity` fragment for ONE engine instance. * @@ -1491,6 +1510,19 @@ function deviceIdentityFragment(): DeviceIdentityFragment { VerifyingKey.fromCryptoKey(pair.publicKey), ]; }, + // THE ENDPOINT ID SURVIVES THE RELOAD, which is the point: this + // device's iroh address is derived from a key that lives in the + // device namespace, so a peer that recorded the id can still dial it + // after both sides have been closed and reopened. Fresh wrappers per + // instance for the registry-identity reason in this function's + // header; the underlying `CryptoKeyPair` is the cached one. + endpointKeyPair: async () => { + const pair = await endpointKey(); + return [ + SigningKey.fromCryptoKey(pair.privateKey), + VerifyingKey.fromCryptoKey(pair.publicKey), + ]; + }, }; } diff --git a/runtime/engine.ts b/runtime/engine.ts index 93b9c68..e7c9b7c 100644 --- a/runtime/engine.ts +++ b/runtime/engine.ts @@ -208,6 +208,12 @@ export interface Driver { usDevicesList(): Promise; usDeviceRevoke(agentId: Uint8Array): Promise; + /** Record THIS device's own iroh endpoint id in the account's device + * map, so the account's other devices can dial it after a reload. + * Called every boot: the engine compares before it writes and authors + * nothing when the stored value already matches. */ + usDeviceEndpointPut(endpoint: Uint8Array): Promise; + /** Drain remotely-caused changes the visor must announce (#22). * Local-echo suppression is engine-side: a device never receives * events for its own writes. */ @@ -300,6 +306,13 @@ export interface UsDevice { name: string; enrolledAt: bigint; revoked: boolean; + /** The device's iroh endpoint id, or EMPTY when none is recorded — + * additive, so entries written before the key existed read back empty + * rather than failing (engine.wit's `us-device`). */ + endpoint: Uint8Array; + /** The agent id of the device that enrolled this one; empty for the + * founding device, and empty additively for older entries. */ + enrolledBy: Uint8Array; } export type UsEvent = @@ -522,6 +535,23 @@ async function persistImports(dir: PersistDir): Promise> */ export interface DeviceIdentityFragment { deviceKeyPair(): Promise<[unknown, unknown] | undefined>; + /** + * The embedder-held TRANSPORT identity — the iroh endpoint key + * (engine.wit's `device-identity.endpoint-key-pair`, where the ruling + * is written out in full). + * + * Consulted at `irohBind()`. `undefined` mints a fresh iroh identity + * per bind, which is what every embedding did before this existed — + * and which means the endpoint id, being the key's public half, died + * with the page session. A persisted pair makes the id stable, so a + * peer that recorded it can still dial after both sides reloaded. + * + * A SECOND PAIR, never the signing one: no key crosses between + * keyhive's signatures and iroh's handshake, and the transport + * identity stays rotatable independently of the account identity. + * Same handle convention as `deviceKeyPair` above. + */ + endpointKeyPair(): Promise<[unknown, unknown] | undefined>; } /** The import key for {@link DeviceIdentityFragment}. */ @@ -536,6 +566,7 @@ export const DEVICE_IDENTITY = "polyvisor:engine/device-identity@0.1.0"; * handle; that fragment is the device-store track's. */ const noDeviceIdentity: DeviceIdentityFragment = { deviceKeyPair: () => Promise.resolve(undefined), + endpointKeyPair: () => Promise.resolve(undefined), }; export async function newEngine( diff --git a/runtime/pairing-engine.ts b/runtime/pairing-engine.ts index 4806ef9..1db2d34 100644 --- a/runtime/pairing-engine.ts +++ b/runtime/pairing-engine.ts @@ -129,12 +129,21 @@ function toMockDevice(d: { name: string; enrolledAt: bigint; revoked: boolean; + endpoint: Uint8Array; + enrolledBy: Uint8Array; }): MockUsDevice { return { agentId: hex(d.agentId), name: d.name, enrolledAt: Number(d.enrolledAt), revoked: d.revoked, + // EMPTY STAYS EMPTY across the hex boundary: `hex(new Uint8Array())` + // is "", which is exactly the visor-side spelling of "not recorded" + // that engine.wit's `us-device` gives these two fields. No + // `undefined` is invented here — an absent endpoint and an unknown + // one are the same fact and deserve one representation. + endpoint: hex(d.endpoint), + enrolledBy: hex(d.enrolledBy), }; } diff --git a/visor/ui/pairing-driver.ts b/visor/ui/pairing-driver.ts index 3a653b3..14f1e05 100644 --- a/visor/ui/pairing-driver.ts +++ b/visor/ui/pairing-driver.ts @@ -81,6 +81,13 @@ export interface UsDevice { name: string; enrolledAt: number; revoked: boolean; + /** The device's iroh endpoint id, hex, or "" when the account has not + * recorded one for it (engine.wit's `us-device`: the field is + * additive, so older entries simply carry nothing). */ + endpoint: string; + /** The agent id of the device that enrolled this one, hex; "" for the + * founding device and for entries older than the field. */ + enrolledBy: string; } export type UsEvent = diff --git a/visor/ui/pairing.ts b/visor/ui/pairing.ts index f2d6bfd..bbd733a 100644 --- a/visor/ui/pairing.ts +++ b/visor/ui/pairing.ts @@ -409,12 +409,24 @@ function saveBootCache(keys: UsCacheKeys, cache: BootCache) { /** After driver init: pull the real profile + marks, compare against * the boot cache, ANNOUNCE any diff (a silently-changed hue/name is * exactly the "anchor that quietly changes" lesson from #22 the - * visor-hue code already carries), then refresh the cache to match. */ + * visor-hue code already carries), then refresh the cache to match. + * + * `onProfile` and `onMarks` are the two ADOPTION halves, and they are + * the consumer's on purpose: this function knows what the account says, + * and only the consumer knows what to repaint with it (the strip is the + * consumer's, and so is the trust table's key). Both are optional, so a + * caller that only wants the cache refreshed and the diff announced — + * every caller before marks crossed — keeps exactly its old behaviour. + * `onMarks` receives the list VERBATIM, repaired view and all: deciding + * which of those marks may be adopted is a trust judgement (PAIRING.md + * §5), and a framework function must not make it on the consumer's + * behalf. */ export async function reconcileFromDriver( driver: PairingDriver, keys: UsCacheKeys, status: AnnounceSink, onProfile?: (profile: UsProfile) => void, + onMarks?: (marks: UsMark[]) => void, ): Promise { const cache = loadBootCache(keys); const profileRes = await driver.usProfileGet(); @@ -430,7 +442,12 @@ export async function reconcileFromDriver( onProfile?.(p); } const marksRes = await driver.usMarksList(); - if (marksRes.ok) saveBootCache(keys, { marks: marksRes.value }); + if (marksRes.ok) { + saveBootCache(keys, { marks: marksRes.value }); + // AFTER the cache, so a consumer that throws out of `onMarks` still + // leaves the cache matching what the account said. + onMarks?.(marksRes.value); + } } // --- join flow: new device (§5) --------------------------------------------