diff --git a/CLAUDE.md b/CLAUDE.md index 29cea84..71cef6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,12 +50,11 @@ blobs & atproto records as files, so the number of writes is kept to a minimum. - `cmd/backupist` — CLI: `enroll`, `export`, `restore`, `set-handle`. - `cmd/backupist-restore` — standalone iroh restore server (dev/test). - `cmd/backupist-restore-wasm` — browser client (`GOOS=js GOARCH=wasm`); exposes - the `ATBackup` v2 API (login/session model — the contract is - `web/src/lib/bridge/types.ts`) plus the legacy `atbackupFetch`-era globals. + the `Backupist` global (login/session model — the contract is + `web/src/lib/bridge/types.ts`), the browser's only entry point into this build. - `web/` — the Vite + Svelte SPA (backup.ist): `make web-dev` to run, `?mock=` to drive it without hardware. Design context in - `PRODUCT.md`/`DESIGN.md` at the repo root. The old hand-rolled test page - lives on at `web/legacy-test.html` until deleted. + `PRODUCT.md`/`DESIGN.md` at the repo root. ## Security invariants — do not break - **(A) The client verifies all downloaded data** against the independently diff --git a/cmd/backupist-restore-wasm/main_js.go b/cmd/backupist-restore-wasm/main_js.go index c24cc05..d4439ea 100644 --- a/cmd/backupist-restore-wasm/main_js.go +++ b/cmd/backupist-restore-wasm/main_js.go @@ -5,101 +5,17 @@ // endpoint entirely in the browser — no Rust, no wasm-bindgen; just the Go // toolchain and the stdlib wasm_exec.js runtime. // -// It exposes three functions to JavaScript: -// -// atbackupFetch(did, connectionKey[, rotationKey]) -> Promise -// Derives the server's endpoint id (same KDF the server used), resolves -// it via n0's pkarr directory, dials it, authorizes with a PAKE, and -// downloads the backup (repo CAR + blobs). Verifies the CAR's commit -// signature and every blob CID against the independently-resolved DID -// document (invariant A). If a rotation key is given, it additionally runs -// the migration engine in dry-run to confirm the key is currently -// authorized to update this DID. The verified backup + parsed rotation key -// are retained for a subsequent atbackupMigrate. The returned summary -// mentions the server's configured owner name (manifest's OwnerName), if -// any. -// -// atbackupMigrate(config) -> Promise -// Runs the full account migration (restore.Migrate with Confirm=true) over -// the backup downloaded by the preceding atbackupFetch, onto config.newPdsUrl. -// config: {newPdsUrl, handle, email, password, serviceAuthToken, -// verificationCode, stopBeforePlc}. The identity update is signed in the -// browser with the fetch's rotation key, which never leaves it (invariant B). -// -// atbackupCheck(did, connectionKey) -> Promise -// "Exercise mode", reported as two independently-checked halves. (a) -// Consistency, fully offline: downloads the backup and runs -// exercise.Examine to prove it's a structurally complete account -// snapshot — computed and returned even if every network lookup below -// fails, since consistency is never hostage to freshness. (b) Freshness -// & authenticity, online: resolves the DID document and verifies the -// commit signature (authentic — a self-consistent CAR could still be -// signed by the wrong key, which only this half can rule out), then -// asks the PDS for its live rev and compares it to the backup's own -// (upToDate). Each of (b)'s checks reports checked/ok/error so "not -// checked because the network failed" is distinguishable from "checked -// and failed". Returns the JSON-marshaled checkReport (exercise.Report's -// fields, plus "authentic", "upToDate", and "ownerName" — the server's -// configured owner name, passed through from the manifest, if any). -// Side-effect-free: no session is retained, so it never enables a -// following atbackupMigrate. -// -// atbackupLogs(did, connectionKey) -> Promise -// Connects and authorizes exactly as the above (same endpoint, same PAKE), -// but asks the server for its recent product log instead of the backup. -// Returns a JSON array of {time, line}, oldest first — recent activity on -// a device with no shell or serial access in hand. Side-effect-free, like -// atbackupCheck. -// -// atbackupStatus(did, connectionKey) -> Promise -// Connects and authorizes exactly as the above, but asks the server for -// its exact, unthrottled current backup status instead of the backup. -// Returns the JSON-marshaled wire.Status (known, percent, done, total, -// complete, rev, rev_as_of, last_checked_at, rate_limited_until, -// owner_name). Side-effect-free, like atbackupCheck. -// -// atbackupWatch(did, connectionKey, onUpdate) -> Promise -// Connects and authorizes exactly as the above, then asks the server for -// a live stream of events instead of a one-shot answer: onUpdate (a JS -// function) is invoked with each frame's JSON-marshaled wire.Event as it -// arrives, for as long as the server keeps sending them. Every event -// carries {event, timestamp, did}; the rest of the fields depend on -// event — "server.hello" (owner, always first), "backup.started" (rev), -// "backup.progress" (progress 0-1, repoBytes/blobs [done,total] tuples, -// backupHorizon), "backup.completed" (rev, backupHorizon, took, -// repoBytes, blobs — no progress field: always immediately preceded by a -// progress:1 event, so a bar rendering off progress alone still reaches -// 100% first), "backup.rate_limited" (until), "backup.failed" (error, -// permanence, retryIn), "keepalive" (no extra fields). See -// internal/wire.Event's doc comment for the full contract, including -// forward compatibility: onUpdate must ignore event kinds and fields it -// doesn't recognise. The promise resolves "watch ended" once the stream -// finishes (the server stopped, or the connection dropped) — that's the -// normal way for this to end, not a failure — and rejects only if -// connecting/authorizing or sending the initial request failed. -// -// These six functions and their behaviour are unchanged from v1. All of the -// connect/authorize/download plumbing they used to implement inline now lives -// in internal/client (a wasm-safe package with no syscall/js, so it compiles -// and tests natively) — see internal/client's package doc comment for the -// session model. Reusing it here, instead of keeping a second copy, means -// v1's atbackupFetch/atbackupCheck also pick up internal/client.Download's -// improvements for free: a stall watchdog instead of a fixed whole-download -// budget, and (atbackupCheck only, since exercise.Examine never looks at -// blob bytes themselves) not retaining blob content it was only ever going -// to verify and discard. -// -// # v2: the ATBackupGlobal bridge -// -// Alongside the six functions above, main() also sets globalThis.Backupist — -// web/src/lib/bridge/types.ts's ATBackupGlobal, apiVersion 2. Where v1 is six -// independent one-shot calls that each pay a fresh derive+connect+auth per -// call and hand back JSON packed into a string, v2 is a persistent, typed -// Session: login(...) once (paying the Argon2id derivation exactly once — -// see internal/kdf, internal/client), then watch/status/logs/check/ -// fetchBackup/migrate/close on the Session it returns. Each of those dials -// its own fresh connection but reuses the session's already-derived secrets -// and bound iroh endpoint (internal/client.Session). +// main() sets globalThis.Backupist — web/src/lib/bridge/types.ts's +// BackupistGlobal — the browser's only entry point into this build. It's a +// persistent, typed Session: login(...) once (paying the +// Argon2id derivation exactly once — see internal/kdf, internal/client), +// then watch/status/logs/check/fetchBackup/migrate/close on the Session it +// returns. Each of those dials its own fresh connection but reuses the +// session's already-derived secrets and bound iroh endpoint +// (internal/client.Session). All of the connect/authorize/download plumbing +// lives in internal/client (a wasm-safe package with no syscall/js, so it +// compiles and tests natively) — see its package doc comment for the +// session model. // // globalThis.Backupist's own methods: // @@ -121,12 +37,11 @@ // migrate(cfg) -> Promise // close() -- releases the session's js.FuncOf values and its iroh endpoint // -// Every v2 result crosses the wasm boundary as json.Marshal followed by one -// JS JSON.parse call (see jsResult) — a single hop, not a JSON string the -// caller has to parse again itself the way v1's string-returning functions -// require. The three Date-typed StatusSnapshot fields (revAsOf, -// lastCheckedAt, rateLimitedUntil) are the one exception: JSON alone can -// only ever produce an ISO string, never a real JS Date instance, so +// Every result crosses the wasm boundary as json.Marshal followed by one JS +// JSON.parse call (see jsResult) — a single hop, not a JSON string the +// caller has to parse again itself. The three Date-typed StatusSnapshot +// fields (revAsOf, lastCheckedAt, rateLimitedUntil) are the one exception: +// JSON alone can only ever produce an ISO string, never a real JS Date instance, so // statusToJS patches those three fields onto the already-parsed object with // an explicit `new Date(...)` afterward (see jsDateOrNull). Every zero // time.Time crosses as `null`, never the Go zero-time string, matching @@ -134,13 +49,12 @@ // // # js.FuncOf discipline // -// The six v1 globals and Backupist's own top-level methods (resolveHandle/ -// login/checkRotationKey/enroll*) are singleton, program-lifetime callbacks: -// set once in main() and never released, same as v1 always did. Everything -// else is scoped narrower: +// Backupist's own top-level methods (resolveHandle/login/checkRotationKey/ +// enroll*) are singleton, program-lifetime callbacks: set once in main() +// and never released. Everything else is scoped narrower: // // - A Session's own methods (watch/status/.../close) are released -// together, exactly once, inside close() itself — see newV2Session. +// together, exactly once, inside close() itself — see newSession. // - A single call's own callbacks — an onProgress passed to check/ // fetchBackup/migrate, or an AbortSignal's "abort" listener registered // by registerAbort — are released when that call settles: its promise @@ -182,248 +96,28 @@ const userAgent = "backupist-restore-wasm" // lookupTimeout bounds each external lookup done after a download completes // (DID doc resolution, PDS getLatestCommit) — deliberately its own fresh // budget, created only after the download itself has already finished, per -// runCheck/doFetch below: a large download consuming a context shared with -// these lookups is the exact bug this split avoids (see the git history of -// this file for the incident that motivated it). +// runCheck/doFetchBackup below: a large download consuming a context shared +// with these lookups is the exact bug this split avoids (see the git +// history of this file for the incident that motivated it). // // dryRunTimeout bounds the optional rotation-key authorization check -// (atbackupFetch/fetchBackup's dry-run Migrate), which does its own PLC -// audit-log fetch(es) — a little more involved than a single lookup, but -// still no data transfer. +// (fetchBackup's dry-run Migrate), which does its own PLC audit-log +// fetch(es) — a little more involved than a single lookup, but still no +// data transfer. const ( lookupTimeout = 20 * time.Second dryRunTimeout = 30 * time.Second ) -// fetchedBackup holds the verified backup + parsed rotation key from the -// most recent atbackupFetch, so a following atbackupMigrate can drive -// restore.Migrate over the already-downloaded data without re-fetching. The -// page is single-user, so one current fetch suffices. (Named fetchedBackup, -// not session, to avoid colliding with internal/client.Session now that -// this file uses that package too.) -type fetchedBackup struct { - did string - rev string - source *restore.MemSource - rotationKey atcrypto.PrivateKeyExportable // nil until a rotation key is supplied to atbackupFetch -} - -var currentFetch *fetchedBackup - func main() { - js.Global().Set("atbackupFetch", js.FuncOf(fetch)) - js.Global().Set("atbackupMigrate", js.FuncOf(migrate)) - js.Global().Set("atbackupCheck", js.FuncOf(check)) - js.Global().Set("atbackupLogs", js.FuncOf(logs)) - js.Global().Set("atbackupStatus", js.FuncOf(status)) - js.Global().Set("atbackupWatch", js.FuncOf(watch)) - setupBackupistGlobal() - if ready := js.Global().Get("__atbackupReady"); ready.Type() == js.TypeFunction { - ready.Invoke() - } - select {} // keep the Go runtime alive for exported callbacks } -// ============================================================================ -// v1: atbackupFetch / atbackupMigrate / atbackupCheck / atbackupLogs / -// atbackupStatus / atbackupWatch — unchanged JS-facing behaviour, now backed -// by internal/client instead of connect/download logic living in this file. -// ============================================================================ - -// fetch(did, connectionKey[, rotationKey]) returns a Promise. -func fetch(_ js.Value, args []js.Value) any { - if len(args) < 2 { - return rejectedPromise("atbackupFetch(did, connectionKey[, rotationKey]) requires at least 2 args") - } - did, connKey := args[0].String(), args[1].String() - var rotationKey string - if len(args) >= 3 && args[2].Type() == js.TypeString { - rotationKey = args[2].String() - } - return promise(func() (string, error) { return doFetch(did, connKey, rotationKey) }) -} - -// check(did, connectionKey) returns a Promise (JSON of exercise.Report). -func check(_ js.Value, args []js.Value) any { - if len(args) < 2 { - return rejectedPromise("atbackupCheck(did, connectionKey) requires 2 args") - } - did, connKey := args[0].String(), args[1].String() - return promise(func() (string, error) { return doCheck(did, connKey) }) -} - -// logs(did, connectionKey) returns a Promise (JSON array of {time, line}). -func logs(_ js.Value, args []js.Value) any { - if len(args) < 2 { - return rejectedPromise("atbackupLogs(did, connectionKey) requires 2 args") - } - did, connKey := args[0].String(), args[1].String() - return promise(func() (string, error) { return doLogs(did, connKey) }) -} - -// status(did, connectionKey) returns a Promise (JSON of wire.Status). -func status(_ js.Value, args []js.Value) any { - if len(args) < 2 { - return rejectedPromise("atbackupStatus(did, connectionKey) requires 2 args") - } - did, connKey := args[0].String(), args[1].String() - return promise(func() (string, error) { return doStatus(did, connKey) }) -} - -// watch(did, connectionKey, onUpdate) returns a Promise. onUpdate is -// invoked with each frame's JSON as it arrives. -func watch(_ js.Value, args []js.Value) any { - if len(args) < 3 { - return rejectedPromise("atbackupWatch(did, connectionKey, onUpdate) requires 3 args") - } - did, connKey := args[0].String(), args[1].String() - onUpdate := args[2] - if onUpdate.Type() != js.TypeFunction { - return rejectedPromise("atbackupWatch(did, connectionKey, onUpdate): onUpdate must be a function") - } - return promise(func() (string, error) { return doWatch(did, connKey, onUpdate) }) -} - -// migrate(config) returns a Promise. See the package doc for config. -func migrate(_ js.Value, args []js.Value) any { - if len(args) < 1 || args[0].Type() != js.TypeObject { - return rejectedPromise("atbackupMigrate(config) requires a config object") - } - cfg := args[0] - str := func(k string) string { - if v := cfg.Get(k); v.Type() == js.TypeString { - return v.String() - } - return "" - } - mc := migrateConfig{ - newPDSURL: str("newPdsUrl"), - handle: str("handle"), - email: str("email"), - password: str("password"), - serviceAuthToken: str("serviceAuthToken"), - verificationCode: str("verificationCode"), - stopBeforePLC: cfg.Get("stopBeforePlc").Truthy(), - } - return promise(func() (string, error) { return doMigrate(mc) }) -} - -// doFetch downloads and verifies a backup for a following atbackupMigrate. -func doFetch(did, connKey, rotationKey string) (string, error) { - ctx := context.Background() - sess, _, err := client.Login(ctx, client.LoginConfig{DID: did, ConnectionKey: connKey}) - if err != nil { - return "", err - } - defer sess.Close(context.Background()) - - dl, err := sess.Download(ctx, client.DownloadOptions{}) - if err != nil { - return "", err - } - - // Invariant (A): resolve the signing key from the DID document independently - // of the server, then verify the repo's commit signature. The server can - // stall or withhold, but it cannot forge a repo signed by the account. A - // fresh, short-lived context, created only now that the download has - // already completed — see the package doc's lookupTimeout entry. - lookupCtx, cancel := context.WithTimeout(context.Background(), lookupTimeout) - defer cancel() - doc, err := verify.ResolveDIDDoc(lookupCtx, http.DefaultClient, plcDirectoryURL, did) - if err != nil { - return "", fmt.Errorf("resolve did doc: %w", err) - } - rev, err := verify.VerifyCommit(lookupCtx, dl.CAR, did, doc.SigningKey) - if err != nil { - return "", err - } - - summary := fmt.Sprintf("VERIFIED rev=%s car=%dB blobs=%d(%dB)", rev, dl.Manifest.CARSize, len(dl.Manifest.Blobs), dl.BlobBytes) - if dl.Manifest.OwnerName != "" { - summary += fmt.Sprintf("; server: %s", dl.Manifest.OwnerName) - } - fb := &fetchedBackup{did: did, rev: rev, source: dl.Source} - - if rotationKey != "" { - // Parse the rotation key and confirm — entirely in the browser — that it - // is currently authorized to update this DID (dry-run Migrate), before - // the user commits to a migration. The key never leaves the browser. - rk, err := restore.ParseRotationKey(rotationKey, "") - if err != nil { - return "", err - } - rkPub, err := rk.PublicKey() - if err != nil { - return "", err - } - dryRunCtx, cancel := context.WithTimeout(context.Background(), dryRunTimeout) - defer cancel() - if err := restore.Migrate(dryRunCtx, restore.Config{ - DID: did, - Source: dl.Source, - RotationKey: rk, - PLCDirectoryURL: plcDirectoryURL, - Confirm: false, - UserAgent: userAgent, - }); err != nil { - return "", err - } - fb.rotationKey = rk - summary += fmt.Sprintf("; rotation key %s AUTHORIZED", rkPub.DIDKey()) - } - - currentFetch = fb - return summary, nil -} - -// checkReport is what atbackupCheck returns to JS: exercise.Report's -// existing consistency fields (rev, asOf, records, blocks, carBytes, blob -// accounting, absent/unfetchable lists, complete) embedded flat exactly as -// they are today — a fully offline claim, computed and reported even if -// every check below fails — plus two independently-reported online checks -// added alongside, never folding into or gating the consistency claim -// above. See runCheck/doCheck. -type checkReport struct { - *exercise.Report - Authentic authenticityCheck `json:"authentic"` - UpToDate freshnessCheck `json:"upToDate"` - // OwnerName is what the backup operator calls themselves - // (wire.Manifest.OwnerName, passed through), if configured on the - // server. Empty when it isn't. - OwnerName string `json:"ownerName,omitempty"` -} - -// authenticityCheck is whether the backup's commit is signed by the -// account's real signing key, resolved independently via its DID document — -// the claim a self-consistent CAR alone can't make. Checked distinguishes -// "we tried and it failed" (wrong/absent signature) from "we couldn't even -// try" (e.g. the DID doc was unreachable): both render as a warning on the -// page, never as the whole check failing, but they mean different things. -type authenticityCheck struct { - Checked bool `json:"checked"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` -} - -// freshnessCheck is whether the backup's rev matches the PDS's live one -// right now. LiveRev/LiveAsOf are populated whenever Checked is true, -// regardless of OK, so a "behind" result can still show both as-of times. -type freshnessCheck struct { - Checked bool `json:"checked"` - OK bool `json:"ok"` - LiveRev string `json:"liveRev"` - LiveAsOf time.Time `json:"liveAsOf"` - Error string `json:"error,omitempty"` -} - // checkOutcome is the neutral result of running exercise mode — download, -// exercise.Examine, then the online authenticity/freshness lookups — shared -// by v1's doCheck (which renders it as the flat JSON shape above) and v2's -// Session.check (which renders it as types.ts's nested CheckReport, see -// doCheckV2), so the check logic itself exists exactly once. +// exercise.Examine, then the online authenticity/freshness lookups — +// rendered as types.ts's nested CheckReport by doCheck (Session.check). type checkOutcome struct { report *exercise.Report ownerName string @@ -498,193 +192,19 @@ func runCheck(ctx context.Context, sess *client.Session, did string, onProgress return out, nil } -// doCheck runs "exercise mode" as two independently-reported halves — see -// the package doc comment's atbackupCheck entry for the product framing. -func doCheck(did, connKey string) (string, error) { - ctx := context.Background() - sess, _, err := client.Login(ctx, client.LoginConfig{DID: did, ConnectionKey: connKey}) - if err != nil { - return "", err - } - defer sess.Close(context.Background()) - - out, err := runCheck(ctx, sess, did, nil) - if err != nil { - return "", err - } - - rep := checkReport{ - Report: out.report, - OwnerName: out.ownerName, - Authentic: authenticityCheck{Checked: out.authenticChecked, OK: out.authenticOK, Error: out.authenticErr}, - UpToDate: freshnessCheck{Checked: out.upToDateChecked, OK: out.upToDateOK, LiveRev: out.liveRev, LiveAsOf: out.liveAsOf, Error: out.upToDateErr}, - } - reportJSON, err := json.Marshal(rep) - if err != nil { - return "", fmt.Errorf("marshal report: %w", err) - } - return string(reportJSON), nil -} - -// doLogs connects and authorizes exactly like doFetch/doCheck, but fetches -// the server's recent product log instead of the backup — side-effect-free, -// same as doCheck; no session is retained. -func doLogs(did, connKey string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - sess, _, err := client.Login(ctx, client.LoginConfig{DID: did, ConnectionKey: connKey}) - if err != nil { - return "", err - } - defer sess.Close(context.Background()) - - lines, err := sess.Logs(ctx) - if err != nil { - return "", err - } - linesJSON, err := json.Marshal(lines) - if err != nil { - return "", fmt.Errorf("marshal logs: %w", err) - } - return string(linesJSON), nil -} - -// doStatus connects and authorizes exactly like doFetch/doCheck/doLogs, but -// fetches the server's exact current backup status instead of the backup — -// side-effect-free; no session is retained. -func doStatus(did, connKey string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - sess, _, err := client.Login(ctx, client.LoginConfig{DID: did, ConnectionKey: connKey}) - if err != nil { - return "", err - } - defer sess.Close(context.Background()) - - st, err := sess.Status(ctx) - if err != nil { - return "", err - } - statusJSON, err := json.Marshal(st) - if err != nil { - return "", fmt.Errorf("marshal status: %w", err) - } - return string(statusJSON), nil -} - -// doWatch connects and authorizes exactly like the other entry points, then -// asks the server for a live stream of did's status and invokes onUpdate — a -// JS function — with each frame's JSON as it arrives. Unlike every other -// request here, this one is deliberately unbounded by a context timeout: -// it's meant to run for as long as the server keeps sending, however long -// that is. -// -// Only a failure before the watch request was successfully sent (logging in, -// or opening/writing the watch stream) rejects the promise; any failure -// after that point — a read erroring, the connection dropping, the server -// closing the stream — just means the watch is over, so it resolves "watch -// ended" instead. internal/client.Session.Watch already makes exactly this -// split (see its doc comment): a non-nil error from it here always means -// setup failed, since ctx (context.Background()) never gets cancelled. -func doWatch(did, connKey string, onUpdate js.Value) (string, error) { - ctx := context.Background() - sess, _, err := client.Login(ctx, client.LoginConfig{DID: did, ConnectionKey: connKey}) - if err != nil { - return "", err - } - defer sess.Close(ctx) - - err = sess.Watch(ctx, func(ev wire.Event) { - if evJSON, err := json.Marshal(ev); err == nil { - onUpdate.Invoke(string(evJSON)) - } - }) - if err != nil { - return "", err - } - return "watch ended", nil -} - -type migrateConfig struct { - newPDSURL, handle, email, password, serviceAuthToken, verificationCode string - stopBeforePLC bool -} - -func doMigrate(mc migrateConfig) (string, error) { - fb := currentFetch - if fb == nil { - return "", fmt.Errorf("no downloaded backup; call atbackupFetch first") - } - if fb.rotationKey == nil { - return "", fmt.Errorf("no rotation key; supply your rotation key to atbackupFetch first") - } - if mc.newPDSURL == "" || mc.handle == "" || mc.email == "" || mc.password == "" { - return "", fmt.Errorf("newPdsUrl, handle, email, and password are required") - } - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) - defer cancel() - - var review bytes.Buffer - err := restore.Migrate(ctx, restore.Config{ - DID: fb.did, - Source: fb.source, - NewPDSURL: mc.newPDSURL, - Handle: mc.handle, - Email: mc.email, - Password: mc.password, - ServiceAuthToken: mc.serviceAuthToken, - VerificationCode: mc.verificationCode, - RotationKey: fb.rotationKey, - PLCDirectoryURL: plcDirectoryURL, - Confirm: true, - StopBeforePLC: mc.stopBeforePLC, - UserAgent: userAgent, - Review: &review, - }) - if err != nil { - return "", err - } - if mc.stopBeforePLC { - return "STAGED (account created + data imported on " + mc.newPDSURL + - "; PLC op built but NOT submitted — identity unchanged)\n\n" + review.String(), nil - } - return fmt.Sprintf("MIGRATED %s → %s (rev %s)\n\n%s", fb.did, mc.newPDSURL, fb.rev, review.String()), nil -} - -// promise wraps a blocking Go function as a JS Promise resolved on a goroutine. -func promise(fn func() (string, error)) any { - return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, p []js.Value) any { - resolve, reject := p[0], p[1] - go func() { - result, err := fn() - if err != nil { - reject.Invoke(jsError(err)) - return - } - resolve.Invoke(result) - }() - return nil - })) -} - -func rejectedPromise(msg string) js.Value { - return js.Global().Get("Promise").Call("reject", js.Global().Get("Error").New(msg)) -} - // ============================================================================ -// v2: globalThis.Backupist — see the package doc comment. +// globalThis.Backupist — see the package doc comment. // ============================================================================ -// setupBackupistGlobal sets globalThis.Backupist to an ATBackupGlobal -// (web/src/lib/bridge/types.ts). Called once from main(); its own methods -// are program-lifetime callbacks, never released — see the package doc -// comment's js.FuncOf discipline. +// setupBackupistGlobal sets globalThis.Backupist to a BackupistGlobal +// (web/src/lib/bridge/types.ts), then invokes globalThis.__backupistReady if +// the page installed one — the deterministic "the bridge is ready" signal +// web/src/lib/bridge/loader.ts waits on (falling back to polling for +// globalThis.Backupist if the callback was never installed). Called once +// from main(); its own methods are program-lifetime callbacks, never +// released — see the package doc comment's js.FuncOf discipline. func setupBackupistGlobal() { backupist := js.Global().Get("Object").New() - backupist.Set("apiVersion", 2) backupist.Set("resolveHandle", js.FuncOf(jsResolveHandle)) backupist.Set("login", js.FuncOf(jsLogin)) backupist.Set("checkRotationKey", js.FuncOf(jsCheckRotationKey)) @@ -692,12 +212,16 @@ func setupBackupistGlobal() { backupist.Set("enrollComplete", js.FuncOf(jsEnrollComplete)) backupist.Set("enrollCancel", js.FuncOf(jsEnrollCancel)) js.Global().Set("Backupist", backupist) + + if cb := js.Global().Get("__backupistReady"); cb.Type() == js.TypeFunction { + cb.Invoke() + } } -// ---- shared v2 plumbing ----------------------------------------------- +// ---- shared bridge plumbing -------------------------------------------- // jsResult marshals v to JSON then parses it straight back into a native JS -// value — the "one hop" every v2 result crosses, per the package doc +// value — the "one hop" every result crosses, per the package doc // comment. Only fails if v itself can't be marshaled, which none of this // file's own result types ever can (plain structs of strings/numbers/ // bools/slices/pointers-to-those). @@ -709,9 +233,9 @@ func jsResult(v any) (js.Value, error) { return js.Global().Get("JSON").Call("parse", string(b)), nil } -// jsError converts a Go error into the JS Error every v1 and v2 rejection -// path resolves with (promise, promiseValue, rejectedPromiseValue all -// funnel through this). A cancelled ctx — from an AbortSignal via +// jsError converts a Go error into the JS Error every rejection path +// resolves with (promiseValue and rejectedPromiseValue both funnel through +// this). A cancelled ctx — from an AbortSignal via // registerAbort, however deep in the call chain it originated, since every // error path in this file and internal/client wraps with %w — is // special-cased: web/src/lib/bridge/types.ts's toBridgeError classifies a @@ -726,8 +250,8 @@ func jsError(err error) js.Value { return js.Global().Get("Error").New(err.Error()) } -// promiseValue is promise's v2 analogue: it resolves a js.Value (built via -// jsResult, typically) instead of the plain strings v1's functions return. +// promiseValue resolves a JS Promise with a js.Value (built via jsResult, +// typically) computed on a goroutine. func promiseValue(fn func() (js.Value, error)) js.Value { return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, p []js.Value) any { resolve, reject := p[0], p[1] @@ -748,7 +272,7 @@ func rejectedPromiseValue(err error) js.Value { } // jsString reads a string property, defaulting to "" if it's absent or not -// a string — used throughout this file's v2 option-object parsing, where +// a string — used throughout this file's option-object parsing, where // every field but a handful of required ones is optional. func jsString(obj js.Value, key string) string { if v := obj.Get(key); v.Type() == js.TypeString { @@ -770,7 +294,7 @@ func setOptionalString(obj js.Value, key, value string) { // maybeTime turns a Go zero time.Time into nil, otherwise a pointer to // itself — the zero-time-means-null convention this file applies at every -// v2 boundary (see docs/PROTOCOL.md's own zero-time convention on the wire). +// boundary (see docs/PROTOCOL.md's own zero-time convention on the wire). func maybeTime(t time.Time) *time.Time { if t.IsZero() { return nil @@ -844,8 +368,7 @@ func registerAbort(signal js.Value, cancel context.CancelFunc) (release func()) // DID document's alsoKnownAs (confirming it names this handle back), but // internal/verify.DIDDoc doesn't currently expose alsoKnownAs (only // SigningKey/PDSEndpoint — see verify.go), and extending it is outside this -// file's edit scope. This trusts the public resolver alone; v1 never had a -// handle-aware code path to compare against. +// file's edit scope. This trusts the public resolver alone. func resolveHandleToDID(ctx context.Context, handle string) (string, error) { u := "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) @@ -928,7 +451,7 @@ func jsLogin(_ js.Value, args []js.Value) any { if err != nil { return js.Value{}, err } - return newV2Session(sess, handle, st.OwnerName, st.OwnerDID, *st), nil + return newSession(sess, handle, st.OwnerName, st.OwnerDID, *st), nil }) } @@ -961,7 +484,7 @@ func (fs *fetchState) get() (*restore.MemSource, atcrypto.PrivateKeyExportable) return fs.source, fs.rotKey } -// newV2Session builds the JS-facing Session object (web/src/lib/bridge/ +// newSession builds the JS-facing Session object (web/src/lib/bridge/ // types.ts's Session interface) wrapping sess. initialStatus is the status // Login already fetched as part of its own round trip (see client.Login's // doc comment) — set here as a plain data property (types.ts declares it @@ -969,14 +492,14 @@ func (fs *fetchState) get() (*restore.MemSource, atcrypto.PrivateKeyExportable) // ever rendering initialView()'s defaults; see state/reduce.ts's seedView. // // Every js.FuncOf created here — including per-watch-call stop() funcs, -// registered via registerFunc, not self-released; see v2Watch's doc comment +// registered via registerFunc, not self-released; see doWatch's doc comment // — is session-scoped and released exactly once, together, in close() // (guarded by closeOnce: types.ts documents close() as idempotent, and // releasing an already-released js.Func is itself an error, not a harmless // no-op). Per-call callbacks that aren't tied to the object's own lifetime // (onProgress, an AbortSignal listener) are separate js.FuncOf values // released when that call settles. -func newV2Session(sess *client.Session, handle, ownerName, ownerDID string, initialStatus wire.Status) js.Value { +func newSession(sess *client.Session, handle, ownerName, ownerDID string, initialStatus wire.Status) js.Value { state := &fetchState{} obj := js.Global().Get("Object").New() @@ -1008,7 +531,7 @@ func newV2Session(sess *client.Session, handle, ownerName, ownerDID string, init if len(args) >= 2 && args[1].Type() == js.TypeObject { signal = args[1].Get("signal") } - return v2Watch(sess, args[0], signal, registerFunc) + return doWatch(sess, args[0], signal, registerFunc) }) bind("status", func(this js.Value, args []js.Value) any { @@ -1046,20 +569,20 @@ func newV2Session(sess *client.Session, handle, ownerName, ownerDID string, init return promiseValue(func() (js.Value, error) { defer cancel() defer release() - return doCheckV2(ctx, sess, sess.DID(), onProgress) + return doCheck(ctx, sess, sess.DID(), onProgress) }) }) bind("confirmRotationKey", func(this js.Value, args []js.Value) any { - return v2ConfirmRotationKey(sess, args) + return doConfirmRotationKey(sess, args) }) bind("fetchBackup", func(this js.Value, args []js.Value) any { - return v2FetchBackup(sess, args, state) + return doFetchBackup(sess, args, state) }) bind("migrate", func(this js.Value, args []js.Value) any { - return v2Migrate(sess, args, state) + return doMigrate(sess, args, state) }) var closeOnce sync.Once @@ -1076,13 +599,13 @@ func newV2Session(sess *client.Session, handle, ownerName, ownerDID string, init return obj } -// v2Watch implements Session.watch: it starts sess.Watch on a goroutine, +// doWatch implements Session.watch: it starts sess.Watch on a goroutine, // bridging each wire.Event to onEvent as a parsed JS object (json.Marshal + -// JSON.parse — one hop, same as every other v2 result), and returns a +// JSON.parse — one hop, same as every other result), and returns a // {stop()} handle immediately. // // stopFunc is registered via registerFunc (the session's own sessionFuncs -// list — see newV2Session) instead of releasing itself once sess.Watch +// list — see newSession) instead of releasing itself once sess.Watch // returns: JS keeps holding the {stop()} object indefinitely (nothing tells // it the watch ended naturally), and calling stop() after a released // js.Func is invalid, not a harmless no-op — the exact bug this avoids. @@ -1092,7 +615,7 @@ func newV2Session(sess *client.Session, handle, ownerName, ownerDID string, init // AbortSignal listener's own release still happens once, right after // sess.Watch returns (that goroutine body only ever runs once, so there's // no double-release risk there). -func v2Watch(sess *client.Session, onEvent js.Value, signal js.Value, registerFunc func(js.Func)) js.Value { +func doWatch(sess *client.Session, onEvent js.Value, signal js.Value, registerFunc func(js.Func)) js.Value { ctx, cancel := context.WithCancel(context.Background()) release := registerAbort(signal, cancel) @@ -1123,7 +646,7 @@ func v2Watch(sess *client.Session, onEvent js.Value, signal js.Value, registerFu // DownloadProgress shape. func progressBridge(cb js.Value) func(client.DownloadProgress) { return func(p client.DownloadProgress) { - v, err := jsResult(downloadProgressV2{ + v, err := jsResult(downloadProgress{ Phase: p.Phase, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal, BlobsDone: p.BlobsDone, BlobsTotal: p.BlobsTotal, }) @@ -1134,7 +657,7 @@ func progressBridge(cb js.Value) func(client.DownloadProgress) { } } -type downloadProgressV2 struct { +type downloadProgress struct { Phase string `json:"phase"` BytesDone int64 `json:"bytesDone"` BytesTotal int64 `json:"bytesTotal"` @@ -1146,7 +669,7 @@ type downloadProgressV2 struct { // plain field in one JSON hop, then the four Date-typed fields are patched // on afterward (see jsDateOrNull's doc comment on why JSON alone can't // produce them). Shared by Session.status() and login's initialStatus (see -// newV2Session) so the two never drift apart. +// newSession) so the two never drift apart. func statusToJS(st wire.Status) js.Value { base := struct { Known bool `json:"known"` @@ -1196,18 +719,18 @@ func validRecord(rec json.RawMessage) json.RawMessage { return rec } -// ---- check (v2) ----------------------------------------------------------- +// ---- check -------------------------------------------------------------- -// checkReportV2 mirrors web/src/lib/bridge/types.ts's CheckReport exactly — -// see runCheck's doc comment for the check itself, shared with v1's doCheck. -type checkReportV2 struct { - Consistency consistencyV2 `json:"consistency"` - Authentic triStateV2 `json:"authentic"` - UpToDate upToDateV2 `json:"upToDate"` - OwnerName string `json:"ownerName,omitempty"` +// checkReport mirrors web/src/lib/bridge/types.ts's CheckReport exactly — +// see runCheck's doc comment for the check itself. +type checkReport struct { + Consistency consistency `json:"consistency"` + Authentic triState `json:"authentic"` + UpToDate upToDate `json:"upToDate"` + OwnerName string `json:"ownerName,omitempty"` } -type consistencyV2 struct { +type consistency struct { Complete bool `json:"complete"` Records int `json:"records"` Blocks int `json:"blocks"` @@ -1220,44 +743,44 @@ type consistencyV2 struct { UnfetchableBlobs []string `json:"unfetchableBlobs"` } -type triStateV2 struct { +type triState struct { Checked bool `json:"checked"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } -type upToDateV2 struct { - triStateV2 +type upToDate struct { + triState LiveRev string `json:"liveRev,omitempty"` LiveAsOf *string `json:"liveAsOf"` } -// doCheckV2 runs runCheck and renders its outcome as checkReportV2. -func doCheckV2(ctx context.Context, sess *client.Session, did string, onProgress func(client.DownloadProgress)) (js.Value, error) { +// doCheck runs runCheck and renders its outcome as checkReport. +func doCheck(ctx context.Context, sess *client.Session, did string, onProgress func(client.DownloadProgress)) (js.Value, error) { out, err := runCheck(ctx, sess, did, onProgress) if err != nil { return js.Value{}, err } - rep := checkReportV2{ - Consistency: consistencyV2{ + rep := checkReport{ + Consistency: consistency{ Complete: out.report.Complete, Records: out.report.Records, Blocks: out.report.Blocks, Rev: out.report.Rev, AsOf: isoOrNil(out.report.AsOf), CARBytes: out.report.CARBytes, BlobsVerified: out.report.BlobsVerified, BlobBytes: out.report.BlobBytes, AbsentBlobs: nonNil(out.report.AbsentBlobs), UnfetchableBlobs: nonNil(out.report.UnfetchableBlobs), }, - Authentic: triStateV2{Checked: out.authenticChecked, OK: out.authenticOK, Error: out.authenticErr}, - UpToDate: upToDateV2{ - triStateV2: triStateV2{Checked: out.upToDateChecked, OK: out.upToDateOK, Error: out.upToDateErr}, - LiveRev: out.liveRev, LiveAsOf: isoOrNil(out.liveAsOf), + Authentic: triState{Checked: out.authenticChecked, OK: out.authenticOK, Error: out.authenticErr}, + UpToDate: upToDate{ + triState: triState{Checked: out.upToDateChecked, OK: out.upToDateOK, Error: out.upToDateErr}, + LiveRev: out.liveRev, LiveAsOf: isoOrNil(out.liveAsOf), }, OwnerName: out.ownerName, } return jsResult(rep) } -// ---- confirmRotationKey (v2) ------------------------------------------- +// ---- confirmRotationKey -------------------------------------------------- -// v2ConfirmRotationKey implements Session.confirmRotationKey(opts?: +// doConfirmRotationKey implements Session.confirmRotationKey(opts?: // {didKey?: string}): Promise — see client.Session.ConfirmRotationKey // for the request itself and what confirming does and doesn't mean (a UX // flag, never proof of key custody). opts (and didKey within it) is @@ -1267,7 +790,7 @@ func doCheckV2(ctx context.Context, sess *client.Session, did string, onProgress // parseRotationKeyMaterial applies) — this rejects immediately, before ever // dialing the server, rather than sending garbage the server would have to // notice is malformed itself. -func v2ConfirmRotationKey(sess *client.Session, args []js.Value) js.Value { +func doConfirmRotationKey(sess *client.Session, args []js.Value) js.Value { var didKeyStr string if len(args) >= 1 && args[0].Type() == js.TypeObject { didKeyStr = jsString(args[0], "didKey") @@ -1285,29 +808,29 @@ func v2ConfirmRotationKey(sess *client.Session, args []js.Value) js.Value { }) } -// ---- fetchBackup (v2) ------------------------------------------------- +// ---- fetchBackup ---------------------------------------------------------- -type fetchResultV2 struct { - Rev string `json:"rev"` - CARBytes int64 `json:"carBytes"` - BlobCount int `json:"blobCount"` - BlobBytes int64 `json:"blobBytes"` - OwnerName string `json:"ownerName,omitempty"` - RotationKey *rotationKeyResultV2 `json:"rotationKey,omitempty"` +type fetchResult struct { + Rev string `json:"rev"` + CARBytes int64 `json:"carBytes"` + BlobCount int `json:"blobCount"` + BlobBytes int64 `json:"blobBytes"` + OwnerName string `json:"ownerName,omitempty"` + RotationKey *rotationKeyResult `json:"rotationKey,omitempty"` } -type rotationKeyResultV2 struct { +type rotationKeyResult struct { DIDKey string `json:"didKey"` Authorized bool `json:"authorized"` } -// v2FetchBackup implements Session.fetchBackup: downloads and verifies the +// doFetchBackup implements Session.fetchBackup: downloads and verifies the // backup (retaining blobs, unlike check's DiscardBlobs), stashes the // resulting restore.MemSource in state for a following migrate, and — if a // rotationKey was supplied — runs a dry-run restore.Migrate to confirm it -// currently authorizes updating this DID, exactly as v1's atbackupFetch -// does (see doFetch). -func v2FetchBackup(sess *client.Session, args []js.Value, state *fetchState) js.Value { +// currently authorizes updating this DID before the caller commits to +// migrating. +func doFetchBackup(sess *client.Session, args []js.Value, state *fetchState) js.Value { var rotationKeyStr string var onProgress func(client.DownloadProgress) var signal js.Value @@ -1333,7 +856,7 @@ func v2FetchBackup(sess *client.Session, args []js.Value, state *fetchState) js. } state.setSource(dl.Source) - result := fetchResultV2{ + result := fetchResult{ Rev: dl.Manifest.Rev, CARBytes: dl.Manifest.CARSize, BlobCount: len(dl.Manifest.Blobs), BlobBytes: dl.BlobBytes, OwnerName: dl.Manifest.OwnerName, @@ -1357,23 +880,23 @@ func v2FetchBackup(sess *client.Session, args []js.Value, state *fetchState) js. return js.Value{}, err } state.setRotationKey(rk) - result.RotationKey = &rotationKeyResultV2{DIDKey: rkPub.DIDKey(), Authorized: true} + result.RotationKey = &rotationKeyResult{DIDKey: rkPub.DIDKey(), Authorized: true} } return jsResult(result) }) } -// ---- migrate (v2) ------------------------------------------------------- +// ---- migrate -------------------------------------------------------------- -type migrateProgressV2 struct { +type migrateProgress struct { Phase string `json:"phase"` Done int `json:"done"` Total int `json:"total"` Message string `json:"message,omitempty"` } -type migrateResultV2 struct { +type migrateResult struct { Staged bool `json:"staged"` Review string `json:"review"` } @@ -1404,11 +927,11 @@ func migrateBudget(src *restore.MemSource) time.Duration { return budget } -// v2Migrate implements Session.migrate: runs restore.Migrate (Confirm:true) +// doMigrate implements Session.migrate: runs restore.Migrate (Confirm:true) // over the backup a preceding fetchBackup downloaded, signing the identity // update with fetchBackup's rotation key (invariant B: it never leaves the // browser). cfg.onProgress is bridged to restore.Config.OnProgress. -func v2Migrate(sess *client.Session, args []js.Value, state *fetchState) js.Value { +func doMigrate(sess *client.Session, args []js.Value, state *fetchState) js.Value { if len(args) < 1 || args[0].Type() != js.TypeObject { return rejectedPromiseValue(fmt.Errorf("migrate(cfg) requires a config object")) } @@ -1430,7 +953,7 @@ func v2Migrate(sess *client.Session, args []js.Value, state *fetchState) js.Valu var onProgress func(restore.ProgressEvent) if cb := cfg.Get("onProgress"); cb.Type() == js.TypeFunction { onProgress = func(ev restore.ProgressEvent) { - v, err := jsResult(migrateProgressV2{Phase: ev.Phase, Done: ev.Done, Total: ev.Total, Message: ev.Message}) + v, err := jsResult(migrateProgress{Phase: ev.Phase, Done: ev.Done, Total: ev.Total, Message: ev.Message}) if err != nil { return } @@ -1468,13 +991,13 @@ func v2Migrate(sess *client.Session, args []js.Value, state *fetchState) js.Valu if err != nil { return js.Value{}, err } - return jsResult(migrateResultV2{Staged: stopBeforePLC, Review: review.String()}) + return jsResult(migrateResult{Staged: stopBeforePLC, Review: review.String()}) }) } // ---- checkRotationKey --------------------------------------------------- -type checkRotationKeyResultV2 struct { +type checkRotationKeyResult struct { Authorized bool `json:"authorized"` RotationKeys []string `json:"rotationKeys"` Reason string `json:"reason,omitempty"` @@ -1510,7 +1033,7 @@ func doCheckRotationKey(ctx context.Context, did, keyStr string) (js.Value, erro return js.Value{}, err } - result := checkRotationKeyResultV2{RotationKeys: state.RotationKeys} + result := checkRotationKeyResult{RotationKeys: state.RotationKeys} if err := restore.AuthorizeRotationKey(state, pub); err != nil { result.Reason = err.Error() } else { @@ -1549,13 +1072,13 @@ var enrollments = struct { m map[string]*enroll.Enrollment }{m: make(map[string]*enroll.Enrollment)} -type enrollPrepareResultV2 struct { - EnrollmentID string `json:"enrollmentId"` - DID string `json:"did"` - RecoveryKey recoveryKeyV2 `json:"recoveryKey"` +type enrollPrepareResult struct { + EnrollmentID string `json:"enrollmentId"` + DID string `json:"did"` + RecoveryKey recoveryKey `json:"recoveryKey"` } -type recoveryKeyV2 struct { +type recoveryKey struct { Secret string `json:"secret"` DIDKey string `json:"didKey"` } @@ -1627,10 +1150,10 @@ func doEnrollPrepare(ctx context.Context, identifier, password, pdsURL, priority enrollments.m[id] = e enrollments.mu.Unlock() - return jsResult(enrollPrepareResultV2{ + return jsResult(enrollPrepareResult{ EnrollmentID: id, DID: e.DID, - RecoveryKey: recoveryKeyV2{Secret: e.RecoveryKey.Multibase(), DIDKey: e.RecoveryKeyDIDKey}, + RecoveryKey: recoveryKey{Secret: e.RecoveryKey.Multibase(), DIDKey: e.RecoveryKeyDIDKey}, }) } diff --git a/docs/HARDWARE-TEST.md b/docs/HARDWARE-TEST.md index 8ac64d4..fc04508 100644 --- a/docs/HARDWARE-TEST.md +++ b/docs/HARDWARE-TEST.md @@ -185,10 +185,9 @@ after boot on a slow network). On the laptop: ```sh -make wasm -cp web/src/wasm/wasm_exec.js web/src/wasm/backupist.wasm web/ -# serve web/ any way you like, then open legacy-test.html and use "Check my backup" -# with the DID and the connection key from the card's backupist.yaml +make web-dev +# open the app, log in with the DID and the connection key from the card's +# backupist.yaml, then use "Check my backup" ``` **Pass:** the report says complete, the record count is plausible, the diff --git a/docs/RESTORE.md b/docs/RESTORE.md index 9ce02cf..fe6d74f 100644 --- a/docs/RESTORE.md +++ b/docs/RESTORE.md @@ -137,69 +137,55 @@ Browser (static site, wasm) Owner's box (backupist) `--did`, `--connection-key`, `--demo-seed`, `--seed-from-pds`). Stays as a dev/test harness now that `internal/daemon` also serves (see P5). - **cmd/backupist-restore-wasm** — the browser client (`//go:build js`), exposes - `atbackupFetch(did, connectionKey[, rotationKey]) -> Promise`. With - only `(did, connectionKey)` it downloads + verifies (invariant A). Given a - `rotationKey` too, it also runs the migration engine in **dry-run** mode - (see P4a) — the key never leaves the browser. Its summary line mentions the - server's configured owner name (`wire.Manifest.OwnerName`), if any. It also - exposes `atbackupCheck(did, connectionKey) -> Promise` (exercise - mode, reported as two independently-checked halves: JSON-marshaled - `checkReport` — `exercise.Report`'s consistency fields, computed offline - and returned even if everything below fails, embedded flat as they are - today, plus `authentic: {checked, ok, error}` and - `upToDate: {checked, ok, liveRev, liveAsOf, error}` for the online - authenticity/freshness half — each distinguishing "not checked because the - network failed" from "checked and failed" — and `ownerName`, passed through - from the manifest), - `atbackupLogs(did, connectionKey) -> Promise` (JSON array of - `{time, line}`, oldest first — the server's recent product log instead of - its backup), `atbackupStatus(did, connectionKey) -> Promise` - (JSON-marshaled `wire.Status` — the server's exact current backup status - instead of its backup, once, including `owner_name`), and - `atbackupWatch(did, connectionKey, - onUpdate) -> Promise` (invokes `onUpdate`, a JS function, with each - `wire.Event` frame's JSON as it arrives over a long-lived stream — see the - internal/wire bullet above for the event vocabulary; the promise resolves - `"watch ended"` once the server stops sending, rejects only if - connecting/authorizing failed). All five share a - `connectAuthorized` helper for the connect+PAKE plumbing, capped at - `connectTimeout` (~30s) regardless of the caller's own budget; - `atbackupFetch`/`atbackupCheck` additionally share `downloadBackup` on top - of it, which gives the transfer itself its own separate `downloadTimeout` - (10 minutes — generous, since the serve side streams steadily even for a - large repo). Each then does its DID-doc/PDS lookups on a **fresh** - `lookupTimeout` (~20s) context created only after the download has - finished, instead of one deadline shared across the whole operation — the - fix for a real bug: a large download alone could consume nearly all of a - single shared budget, leaving these lookups to fail with "context deadline - exceeded" even though they'd succeed instantly on their own. - Manifest-carriage (rather than a preamble frame, or a status-first fetch) - is deliberate: every connection already opens with exactly one - request/response pair, so the owner name rides whichever response the - client asked for, and a status-first fetch would double the per-connection - Argon2id cost in the browser for no gain. + a single global, `globalThis.Backupist` (contract: `web/src/lib/bridge/ + types.ts`; exact shapes on `main_js.go`'s package doc comment). + `Backupist.login(opts)` resolves a handle/DID, authorizes over iroh+PAKE + (paying the Argon2id derivation exactly once), and returns a `Session` + bound to that account; every further call opens its own fresh connection + but reuses the session's already-derived secrets. The `Session`: + - `fetchBackup(opts?)` downloads + verifies the backup (invariant A) and, + given a `rotationKey`, additionally runs the migration engine in + **dry-run** mode (see P4a) to confirm it currently authorizes updating + the DID — the key never leaves the browser. Resolves with rev/size/blob + accounting plus the server's configured owner name, if any. + - `check(opts?)` runs exercise mode, reported as two independently-checked + halves: `consistency` (`internal/exercise.Examine`'s fields, computed + offline and returned even if everything below fails) plus + `authentic`/`upToDate` (online, each `{checked, ok, error}` — + distinguishing "not checked because the network failed" from "checked + and failed") and `ownerName`, passed through from the manifest. + - `logs()` resolves with the server's recent product log, oldest first, + instead of its backup. + - `status()` resolves with the server's exact current backup status, once, + instead of its backup. + - `watch(onEvent, opts?)` invokes `onEvent` with each `wire.Event` frame's + parsed JSON as it arrives over a long-lived stream — see the + internal/wire bullet above for the event vocabulary — and returns a + `{stop()}` handle immediately; the underlying goroutine ends once the + server stops sending or `stop()` is called. + - `migrate(cfg)` runs the full account migration + (`restore.Migrate(Confirm:true)`) over a preceding `fetchBackup`'s + downloaded data, signing the identity update in the browser with its + rotation key (invariant B). + + The connect+authorize plumbing (`internal/client`, factored out of this + package so it compiles and tests natively too) caps each dial at + `defaultConnectTimeout` (~30s); a download doesn't get one fixed + whole-transfer budget, since that penalizes a large legitimate transfer — + instead a stall watchdog (`defaultStallTimeout`, ~90s) resets on every + read, so only a peer that actually goes silent mid-transfer fails. Each + call's DID-doc/PDS lookups then run on their own **fresh** `lookupTimeout` + (~20s) context created only after the download has finished, instead of + one deadline shared across the whole operation — the fix for a real bug: a + large download alone could consume nearly all of a single shared budget, + leaving these lookups to fail with "context deadline exceeded" even though + they'd succeed instantly on their own. Manifest-carriage (rather than a + preamble frame, or a status-first fetch) is deliberate: every connection + already opens with exactly one request/response pair, so the owner name + rides whichever response the client asked for, and a status-first fetch + would double the per-connection Argon2id cost in the browser for no gain. - **web/** — the Vite + Svelte SPA (see CLAUDE.md; `make web-dev`, mock - scenarios via `?mock=`). Its predecessor, kept until deleted: -- **web/legacy-test.html** — the original minimal HTML test page, - with "Check my backup" and "Recent activity" sections alongside the - fetch/migrate flow; "Recent activity" fetches both status and logs and - shows the status as a headline above the log lines. A "Watch live" button - drives `atbackupWatch`'s event stream (`renderWatchEvent`, switching on - `event`) into a real progress bar plus a "repo X/Y MB · blobs A/B" detail - line (`backup.progress`), a line locked at 100% with the horizon - (`backup.completed`), the rate-limit line (`backup.rate_limited`), and a - failed line distinguishing permanent from transient - (`backup.failed`) — `keepalive` and any event kind the page doesn't - recognise are silently ignored (the latter logged at most once). - Whichever of check/fetch/activity/watch first sees an owner name - (`ownerName`/`owner_name`/watch's own `server.hello` event) logs "connected - to <name>'s backup server" once per session (`noteOwnerName`), and - both `renderStatusHeadline` and the "Check my backup" consistency headline - prefix themselves with "<name>'s backup — " when it's set. Exactly - one `escapeHTML` guards it wherever it lands in `innerHTML`, since it's - operator-controlled text, not derived/verified data like the rest of these - pages' content — `renderWatchEvent`'s own headline only ever reaches - `.textContent`, which needs none. + scenarios via `?mock=`). ### The local migration engine (`internal/restore`) `internal/restore` (package `restore`: `Migrate`, `Config`, `NewPDSClient`, @@ -283,6 +269,14 @@ by your migration engine). data integrity or identity (see the plan for the full analysis). ## Status +*(The `atbackupFetch`/`atbackupMigrate`/`atbackupCheck`/`atbackupLogs`/ +`atbackupStatus`/`atbackupWatch` globals named below were the wasm build's +original one-shot API, current when each phase was proven. They were later +replaced entirely by the persistent `Backupist`/`Session` bridge described +in the cmd/backupist-restore-wasm bullet above (contract: +`web/src/lib/bridge/types.ts`) — the history below is preserved as a record +of what was proven and when, not a description of the current API.)* + Done on `restore-transport`: **P1** transport, **P2** discovery+PAKE, **P3a/b** serve+download, **P3c** verify, and **P4a** the in-browser read/verify/authorize half of migration. Proven end-to-end in a real browser against the real ~18.7 MB @@ -299,9 +293,10 @@ submitPlcOperation → activate) is **proven end-to-end via the CLI** against a real PDS (eurosky), including the enroll prerequisite and the captcha-gate verification handoff (see the migration-engine section above). -**P4b: proven from the browser too.** The wasm client now runs the *same* -`restore.Migrate(Confirm:true)` over the iroh-downloaded `MemSource` via -`atbackupMigrate(config)`. Confirmed in a real browser: after download → verify → +**P4b: proven from the browser too.** The wasm client ran the *same* +`restore.Migrate(Confirm:true)` over the iroh-downloaded `MemSource`, via what +was then `atbackupMigrate(config)` (now `Session.migrate`). Confirmed in a +real browser: after download → verify → authorize, it drove createAccount, importRepo (binary CAR POST), uploadBlob (binary blob POST), getRecommendedDidCredentials, a client-signed submitPlcOperation, activate, and checkAccountStatus — every call reaching the @@ -311,9 +306,10 @@ so no real identity changed; the engine itself is the CLI-proven one.) **Two P5 items done: the daemon serves restores, and exercise mode.** `internal/daemon.Run` now starts a serving goroutine per `serve.connection_keys` -entry (see P5 below); `internal/exercise.Examine` plus `atbackupCheck` let a -user prove a backup is a complete account snapshot — records present, blobs -verified, nothing silently missing — without migrating anything. +entry (see P5 below); `internal/exercise.Examine` plus what was then +`atbackupCheck` (now `Session.check`) let a user prove a backup is a +complete account snapshot — records present, blobs verified, nothing +silently missing — without migrating anything. ## Remaining work @@ -350,11 +346,11 @@ account) already exist in the engine; the browser can reuse the same seams. `make web-compress` emits gzip/brotli sidecars — ~6.4 MB brotli actually crosses the network, and Vite content-hashes the artifact for immutable caching. Further uncompressed shrink would need dependency surgery. -- **Real UX — DONE.** `web/` is a Vite + Svelte 5 SPA: handle + ATBackup - Phrase login, "Backed up as of" toolkit driven by the watch event stream, +- **Real UX — DONE.** `web/` is a Vite + Svelte 5 SPA: handle + connection-phrase + login, "Backed up as of" toolkit driven by the watch event stream, verify with the two-halves report, first-use rotation-key onboarding (browser enroll), and the full restore wizard incl. captcha gate-return - and staged/resume commit. The wasm ships an `ATBackup` v2 session API + and staged/resume commit. The wasm ships a `Backupist` session API (contract: `web/src/lib/bridge/types.ts`; engine: `internal/client`). - **Fold the server into backupistd — DONE.** One iroh endpoint per served account, not one endpoint with per-DID PAKE: a new optional `serve: @@ -467,9 +463,10 @@ account) already exist in the engine; the browser can reuse the same seams. (progress bar + detail line, a locked-at-100 completed line with the horizon, the rate-limited line, a failed line distinguishing permanent from transient) and logs "connected to ``'s backup server" once, - from `server.hello`. `cmd/backupist-restore-wasm`'s `atbackupWatch` is - mechanically unchanged (still delivers each frame's JSON to `onUpdate`) — - only its doc comment describes the new payload. `backupist status --watch` + from `server.hello`. `cmd/backupist-restore-wasm`'s (then-)`atbackupWatch`, + since replaced by `Session.watch`, was mechanically unchanged by this + (still delivered each frame's JSON to `onUpdate`) — only its doc comment + described the new payload. `backupist status --watch` is new: same connect+authorize plumbing as the one-shot fetch (`connectAndAuthorize`, extracted from `fetchOne`), then prints one friendly line per meaningful event (keepalives silent) until Ctrl-C @@ -488,12 +485,10 @@ go build ./cmd/backupist-restore # Or: backupistd/backupist-gosd serve real backups directly, per the `serve: # connection_keys:` section in backupist.example.yaml — no separate process. -# Browser client wasm (pure Go, no Rust toolchain): -make wasm -cp web/src/wasm/wasm_exec.js web/src/wasm/backupist.wasm web/ -# then serve web/ statically and open legacy-test.html: -# /legacy-test.html?did=&key=&auto=1 -# "Check my backup" (atbackupCheck) uses the same did/key fields; no auto-run flag yet. +# Browser client wasm (pure Go, no Rust toolchain), then the SPA that loads it: +make web-dev +# open the app, log in with a did/handle + connection key, then use +# "Check my backup" (Session.check) or the restore wizard. # Tests (internal/verify has a live test against a real DID): go test ./internal/... diff --git a/web/legacy-test.html b/web/legacy-test.html deleted file mode 100644 index 8c348df..0000000 --- a/web/legacy-test.html +++ /dev/null @@ -1,383 +0,0 @@ - - - - - - - -atbackup restore - - - - - -

atbackup restore

-

A relay-only go-iroh endpoint in this browser (WebAssembly, no Rust) connects to the owner's - atbackup server, downloads + verifies the backup, then rebuilds the account on a new PDS — - signing the identity update with a rotation key that never leaves this page.

- -

1 · Connect & verify

- - - - -

-
-  

Check my backup

-

Prove the backup works without migrating anything, in two independent halves. First, - fully offline: it's a consistent account snapshot — every block the repo's tree - reaches is present, and every referenced blob was found and hashes correctly. Then, online: - it's authentic (signed by this account's real key — a consistent CAR alone can't - prove that) and up to date with the live account. The second half needs the network; - if it can't be reached, the first half's result still stands on its own — you'll see a warning, - not a failure. Uses the same DID + connection key above. No session is kept; checking has no - side effects.

- -

-
-  

Recent activity

-

See how the backup is doing right now, and what the device has been doing lately — connects - over the same iroh link with the same DID + connection key above, behind the same - authorization, and returns the exact current backup status plus the server's recent log - lines (backup progress, scrub reports, jetstream connects, ...), oldest first.

- -

-
-  

Or watch it live, streamed over the same iroh connection instead of polling:

-

-  
- - -

2 · Migrate to a new PDS

-
- - -
-
- - -
- - - - -

-
-  

log

-

-
-
diff --git a/web/src/lib/bridge/loader.ts b/web/src/lib/bridge/loader.ts
index 49f9e89..3e6c0a1 100644
--- a/web/src/lib/bridge/loader.ts
+++ b/web/src/lib/bridge/loader.ts
@@ -58,15 +58,14 @@ export async function loadWasm(
   const { instance } = await WebAssembly.instantiate(bytes, go.importObject);
 
   const ready = new Promise((resolve, reject) => {
-    (globalThis as Record).__atbackupReady = () => resolve();
+    (globalThis as Record).__backupistReady = () => resolve();
 
-    // The wasm build doesn't call __atbackupReady yet — it just assigns its
-    // exported functions during main() with no separate readiness signal.
-    // Poll for one of them as a fallback so the loader still works today;
-    // once the wasm calls __atbackupReady this path simply never wins.
+    // main() invokes __backupistReady right after setting globalThis.Backupist,
+    // so this normally settles the promise immediately. The poll below is
+    // just a belt-and-braces fallback in case that callback is ever missed.
     const deadline = Date.now() + 15_000;
     const poll = () => {
-      if (typeof (globalThis as Record).atbackupFetch === "function") {
+      if (typeof (globalThis as Record).Backupist === "object") {
         resolve();
       } else if (Date.now() < deadline) {
         setTimeout(poll, 200);
diff --git a/web/src/lib/bridge/mock.ts b/web/src/lib/bridge/mock.ts
index 381f71b..c14d082 100644
--- a/web/src/lib/bridge/mock.ts
+++ b/web/src/lib/bridge/mock.ts
@@ -352,8 +352,6 @@ function makeSession(scenario: ScenarioName): Session {
  * available scripts. */
 export function createMockBridge(scenario: ScenarioName): BackupistGlobal {
   return {
-    apiVersion: 2,
-
     async resolveHandle(handle) {
       return { did: MOCK_DID, handle };
     },
diff --git a/web/src/lib/bridge/types.ts b/web/src/lib/bridge/types.ts
index 3c7a553..b1c2281 100644
--- a/web/src/lib/bridge/types.ts
+++ b/web/src/lib/bridge/types.ts
@@ -1,24 +1,18 @@
-// The v2 bridge contract: the shape a browser-side "backupist engine" must
-// satisfy, whether it's the real wasm build (internal/wire, internal/verify,
-// internal/restore compiled to js/wasm), the v1 wasm build adapted to look
-// like v2 (see v1-adapter.ts), or a scripted mock for UI development (see
-// mock.ts). This file IS the spec — the Go side implements it, this side
-// consumes it. Everything downstream (state/reduce.ts, state/checkReport.ts,
-// the Svelte components) is written against these types only, never against
-// a concrete engine, so swapping v1-adapter.ts for the real v2 wasm build
-// later is a one-line change.
+// The Backupist bridge contract: the shape a browser-side "backupist engine"
+// must satisfy, whether it's the real wasm build (internal/wire,
+// internal/verify, internal/restore compiled to js/wasm) or a scripted mock
+// for UI development (see mock.ts). This file IS the spec — the Go side
+// implements it, this side consumes it. Everything downstream
+// (state/reduce.ts, state/checkReport.ts, the Svelte components) is written
+// against these types only, never against a concrete engine.
 //
-// Wire vocabulary lives in docs/PROTOCOL.md; the v1 JS surface's exact
-// globals and payload shapes are documented on
+// Wire vocabulary lives in docs/PROTOCOL.md; this file's own exact globals
+// and payload shapes are documented on
 // cmd/backupist-restore-wasm/main_js.go's package doc comment. This file's
 // job is to give that same information a typed, browser-native shape.
 
-/** The one global the page looks for. `apiVersion` lets the loader refuse to
- * drive a wasm build it doesn't understand, instead of failing confusingly
- * partway through a call. */
+/** The one global the page looks for. */
 export interface BackupistGlobal {
-  readonly apiVersion: 2;
-
   /** Resolves a handle to a DID via the same public, unauthenticated
    * lookup a browser can always reach (no PDS session needed). Rejects with
    * `HANDLE_UNRESOLVED` if the handle doesn't resolve. */
@@ -97,8 +91,8 @@ export interface Session {
   /** Opens a live event stream and invokes `onEvent` for each frame,
    * exactly as they arrive (see `WatchEvent`). Returns a handle whose
    * `stop()` ends delivery; not every engine can tear down the underlying
-   * connection early (see v1-adapter.ts), so `stop()` is a "stop caring",
-   * not necessarily a "stop connecting", guarantee. */
+   * connection early (see mock.ts), so `stop()` is a "stop caring", not
+   * necessarily a "stop connecting", guarantee. */
   watch(onEvent: (e: WatchEvent) => void, opts?: { signal?: AbortSignal }): { stop(): void };
 
   /** The server's exact, unthrottled current status — a one-shot poll
@@ -280,9 +274,8 @@ export interface MigrateProgress {
   message?: string;
 }
 
-/** The result of `fetchBackup`: what was verified, standing in for the v1
- * summary string (see v1-adapter.ts's `parseFetchSummary`) with structured
- * fields a UI can actually lay out. */
+/** The result of `fetchBackup`: what was verified, as structured fields a UI
+ * can lay out directly. */
 export interface FetchResult {
   rev: string;
   carBytes: number;
@@ -308,9 +301,8 @@ export interface TriState {
 
 /**
  * The result of `check`, reported as two independently-computed halves —
- * see cmd/backupist-restore-wasm/main_js.go's `doCheck` doc comment and
- * web/legacy-test.html's renderConsistency/renderFreshness, which this type
- * exists to let state/checkReport.ts re-implement without any DOM.
+ * see cmd/backupist-restore-wasm/main_js.go's `doCheck` doc comment;
+ * state/checkReport.ts renders this into display lines without any DOM.
  *
  * `consistency` is fully offline: is this a structurally complete account
  * snapshot? Always computed, even if every check below fails — consistency
@@ -407,8 +399,7 @@ export class BridgeError extends Error {
  * Classifies an unknown thrown value into a `BridgeError`, by matching
  * known engine error strings. `BridgeError`s pass through unchanged;
  * everything else is pattern-matched against messages known to come out of
- * the v1 wasm build and its supporting packages, falling back to
- * `UNKNOWN`.
+ * the wasm build and its supporting packages, falling back to `UNKNOWN`.
  *
  * Patterns and their source:
  *  - `"authorization failed (wrong connection key)"` — internal/pake.ErrAuthFailed
diff --git a/web/src/lib/bridge/v1-adapter.test.ts b/web/src/lib/bridge/v1-adapter.test.ts
deleted file mode 100644
index 868ffc8..0000000
--- a/web/src/lib/bridge/v1-adapter.test.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { createV1Bridge, type V1Globals } from "./v1-adapter";
-import { BridgeError } from "./types";
-import type { WatchEvent } from "./types";
-
-const DID = "did:plc:test0000000000000000000000";
-const PHRASE = "correct horse battery staple";
-
-function stubGlobals(overrides: Partial = {}): V1Globals {
-  return {
-    atbackupFetch: vi.fn(async () => "VERIFIED rev=r1 car=100B blobs=2(50B)"),
-    atbackupMigrate: vi.fn(async () => "MIGRATED did:plc:test -> https://new.example (rev r1)"),
-    atbackupCheck: vi.fn(async () =>
-      JSON.stringify({
-        rev: "r1",
-        asOf: "2026-07-14T11:00:00Z",
-        records: 10,
-        blocks: 20,
-        carBytes: 100,
-        blobsVerified: 2,
-        blobBytes: 50,
-        absentBlobs: [],
-        unfetchableBlobs: [],
-        complete: true,
-        authentic: { checked: true, ok: true },
-        upToDate: { checked: true, ok: true, liveRev: "r1", liveAsOf: "2026-07-14T11:00:00Z" },
-        ownerName: "Jamie",
-      }),
-    ),
-    atbackupLogs: vi.fn(async () => JSON.stringify([{ time: "2026-07-14T11:00:00Z", line: "sync: complete" }])),
-    atbackupStatus: vi.fn(async () =>
-      JSON.stringify({
-        known: true,
-        percent: 100,
-        done: 10,
-        total: 10,
-        complete: true,
-        rev: "r1",
-        rev_as_of: "2026-07-14T11:00:00Z",
-        last_checked_at: "2026-07-14T11:05:00Z",
-        rate_limited_until: "0001-01-01T00:00:00Z",
-        owner_name: "Jamie",
-      }),
-    ),
-    atbackupWatch: vi.fn(async () => "watch ended"),
-    ...overrides,
-  };
-}
-
-afterEach(() => {
-  vi.unstubAllGlobals();
-});
-
-describe("resolveHandle", () => {
-  it("resolves a handle via the public bsky API", async () => {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn(async () => new Response(JSON.stringify({ did: DID }), { status: 200 })),
-    );
-    const bridge = createV1Bridge(stubGlobals());
-    await expect(bridge.resolveHandle("jamie.example")).resolves.toEqual({ did: DID, handle: "jamie.example" });
-  });
-
-  it("rejects with HANDLE_UNRESOLVED on a non-ok response", async () => {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn(async () => new Response("nope", { status: 400 })),
-    );
-    const bridge = createV1Bridge(stubGlobals());
-    await expect(bridge.resolveHandle("nope.example")).rejects.toMatchObject({ code: "HANDLE_UNRESOLVED" });
-  });
-});
-
-describe("login", () => {
-  it("logs in directly with a DID, validating via atbackupStatus", async () => {
-    const globals = stubGlobals();
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    expect(session.did).toBe(DID);
-    expect(globals.atbackupStatus).toHaveBeenCalledWith(DID, PHRASE);
-  });
-
-  it("resolves a handle first when only a handle is given", async () => {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn(async () => new Response(JSON.stringify({ did: DID }), { status: 200 })),
-    );
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ handle: "jamie.example", phrase: PHRASE });
-    expect(session.did).toBe(DID);
-  });
-
-  it("maps a rejected atbackupStatus call through toBridgeError", async () => {
-    const globals = stubGlobals({
-      atbackupStatus: vi.fn(async () => {
-        throw new Error("pake: authorization failed (wrong connection key)");
-      }),
-    });
-    const bridge = createV1Bridge(globals);
-    await expect(bridge.login({ did: DID, phrase: "wrong" })).rejects.toMatchObject({ code: "PAKE_REJECTED" });
-  });
-});
-
-describe("Session.status", () => {
-  it("maps snake_case v1 JSON to a camelCase StatusSnapshot, resolving zero-times to null", async () => {
-    const globals = stubGlobals();
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const status = await session.status();
-    expect(status).toMatchObject({
-      known: true,
-      percent: 100,
-      complete: true,
-      rev: "r1",
-      ownerName: "Jamie",
-    });
-    expect(status.revAsOf).toEqual(new Date("2026-07-14T11:00:00Z"));
-    expect(status.rateLimitedUntil).toBeNull();
-  });
-});
-
-describe("Session.logs", () => {
-  it("parses the JSON array of log lines", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    await expect(session.logs()).resolves.toEqual([{ time: "2026-07-14T11:00:00Z", line: "sync: complete" }]);
-  });
-});
-
-describe("Session.check", () => {
-  it("reshapes v1's flat JSON into the two-halves CheckReport", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const report = await session.check();
-    expect(report.consistency).toMatchObject({ complete: true, records: 10, rev: "r1", carBytes: 100 });
-    expect(report.authentic).toEqual({ checked: true, ok: true });
-    expect(report.upToDate).toMatchObject({ checked: true, ok: true, liveRev: "r1" });
-    expect(report.ownerName).toBe("Jamie");
-  });
-
-  it("invokes onProgress at least once", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const onProgress = vi.fn();
-    await session.check({ onProgress });
-    expect(onProgress).toHaveBeenCalled();
-  });
-});
-
-describe("Session.fetchBackup", () => {
-  it("parses the VERIFIED summary string into a structured FetchResult", async () => {
-    const globals = stubGlobals({
-      atbackupFetch: vi.fn(
-        async () => "VERIFIED rev=r1 car=19396142B blobs=1190(4200000B); server: Jamie; rotation key did:key:zAbc AUTHORIZED",
-      ),
-    });
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const result = await session.fetchBackup({ rotationKey: "the-rotation-key" });
-
-    expect(result).toEqual({
-      rev: "r1",
-      carBytes: 19_396_142,
-      blobCount: 1190,
-      blobBytes: 4_200_000,
-      ownerName: "Jamie",
-      rotationKey: { didKey: "did:key:zAbc", authorized: true },
-    });
-    expect(globals.atbackupFetch).toHaveBeenCalledWith(DID, PHRASE, "the-rotation-key");
-  });
-
-  it("parses a summary with neither owner nor rotation key", async () => {
-    const globals = stubGlobals({ atbackupFetch: vi.fn(async () => "VERIFIED rev=r1 car=100B blobs=2(50B)") });
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const result = await session.fetchBackup();
-    expect(result).toEqual({ rev: "r1", carBytes: 100, blobCount: 2, blobBytes: 50, ownerName: undefined });
-  });
-});
-
-describe("Session.migrate", () => {
-  it("maps a STAGED-prefixed result to staged: true", async () => {
-    const globals = stubGlobals({
-      atbackupMigrate: vi.fn(async () => "STAGED (account created; PLC op built but NOT submitted)"),
-    });
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const result = await session.migrate({
-      newPdsUrl: "https://new.example",
-      handle: "jamie.new.example",
-      email: "jamie@example.com",
-      password: "hunter2",
-      stopBeforePlc: true,
-    });
-    expect(result.staged).toBe(true);
-  });
-
-  it("maps a non-STAGED result to staged: false", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const result = await session.migrate({
-      newPdsUrl: "https://new.example",
-      handle: "jamie.new.example",
-      email: "jamie@example.com",
-      password: "hunter2",
-    });
-    expect(result.staged).toBe(false);
-  });
-
-  it("rejects resume with an UNKNOWN BridgeError rather than calling atbackupMigrate", async () => {
-    const globals = stubGlobals();
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    await expect(
-      session.migrate({
-        newPdsUrl: "https://new.example",
-        handle: "jamie.new.example",
-        email: "jamie@example.com",
-        password: "hunter2",
-        resume: true,
-      }),
-    ).rejects.toBeInstanceOf(BridgeError);
-    expect(globals.atbackupMigrate).not.toHaveBeenCalled();
-  });
-});
-
-describe("Session.watch", () => {
-  it("parses each JSON frame and delivers it to onEvent", async () => {
-    const events: WatchEvent[] = [];
-    const globals = stubGlobals({
-      atbackupWatch: vi.fn(async (_did: string, _phrase: string, onUpdate: (json: string) => void) => {
-        onUpdate(JSON.stringify({ event: "server.hello", did: DID, timestamp: "2026-07-14T11:00:00Z", owner: "Jamie" }));
-        onUpdate(JSON.stringify({ event: "backup.started", did: DID, timestamp: "2026-07-14T11:01:00Z", rev: "r1" }));
-        return "watch ended";
-      }),
-    });
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    session.watch((e) => events.push(e));
-    await vi.waitFor(() => expect(events).toHaveLength(2));
-    expect(events[0].event).toBe("server.hello");
-    expect(events[1].event).toBe("backup.started");
-  });
-
-  it("stop() drops further events without throwing", async () => {
-    const events: WatchEvent[] = [];
-    let capturedOnUpdate: ((json: string) => void) | undefined;
-    const globals = stubGlobals({
-      atbackupWatch: vi.fn(async (_did: string, _phrase: string, onUpdate: (json: string) => void) => {
-        capturedOnUpdate = onUpdate;
-        return "watch ended";
-      }),
-    });
-    const bridge = createV1Bridge(globals);
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    const handle = session.watch((e) => events.push(e));
-    await vi.waitFor(() => expect(capturedOnUpdate).toBeDefined());
-    handle.stop();
-    capturedOnUpdate?.(JSON.stringify({ event: "keepalive", did: DID, timestamp: "2026-07-14T11:00:00Z" }));
-    expect(events).toHaveLength(0);
-  });
-});
-
-describe("unsupported v1 gaps", () => {
-  it("checkRotationKey rejects with UNKNOWN", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    await expect(bridge.checkRotationKey({ did: DID, key: "x" })).rejects.toMatchObject({ code: "UNKNOWN" });
-  });
-
-  it("Session.confirmRotationKey rejects with UNKNOWN, pointing at an outdated server", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    const session = await bridge.login({ did: DID, phrase: PHRASE });
-    await expect(session.confirmRotationKey()).rejects.toMatchObject({
-      code: "UNKNOWN",
-      message: expect.stringMatching(/too old/i),
-    });
-  });
-
-  it("enrollPrepare/enrollComplete reject pointing at the CLI; enrollCancel is a no-op", async () => {
-    const bridge = createV1Bridge(stubGlobals());
-    await expect(bridge.enrollPrepare({ identifier: "jamie.example", password: "x" })).rejects.toMatchObject({
-      code: "UNKNOWN",
-    });
-    await expect(bridge.enrollComplete({ enrollmentId: "1", token: "123456" })).rejects.toMatchObject({
-      code: "UNKNOWN",
-    });
-    expect(() => bridge.enrollCancel({ enrollmentId: "1" })).not.toThrow();
-  });
-});
diff --git a/web/src/lib/bridge/v1-adapter.ts b/web/src/lib/bridge/v1-adapter.ts
deleted file mode 100644
index a45d9b4..0000000
--- a/web/src/lib/bridge/v1-adapter.ts
+++ /dev/null
@@ -1,397 +0,0 @@
-// Adapts today's wasm build — six bare globals (atbackupFetch, Migrate,
-// Check, Logs, Status, Watch), documented on
-// cmd/backupist-restore-wasm/main_js.go's package doc comment — to look like
-// a BackupistGlobal (`apiVersion: 2`). It fulfils the v2 contract, it just
-// can't do everything a real v2 engine could (no cancellation, no
-// incremental progress, no enroll, no standalone rotation-key check, no
-// rotation-key confirmation): each gap is called out below, and callers
-// should expect this adapter to be retired once the wasm build speaks v2
-// natively.
-import { BridgeError, toBridgeError } from "./types";
-import { parseMaybeTime } from "../format/time";
-import type {
-  BackupistGlobal,
-  CheckReport,
-  FetchResult,
-  LogLine,
-  MigrateConfig,
-  MigrateResult,
-  Session,
-  StatusSnapshot,
-  TriState,
-  WatchEvent,
-} from "./types";
-
-/** The six functions v1's wasm build attaches to the global scope. See
- * main_js.go's package doc comment for the exact signatures and payload
- * shapes this adapter parses. */
-export interface V1Globals {
-  atbackupFetch(did: string, connectionKey: string, rotationKey?: string): Promise;
-  atbackupMigrate(config: Record): Promise;
-  atbackupCheck(did: string, connectionKey: string): Promise;
-  atbackupLogs(did: string, connectionKey: string): Promise;
-  atbackupStatus(did: string, connectionKey: string): Promise;
-  atbackupWatch(did: string, connectionKey: string, onUpdate: (eventJSON: string) => void): Promise;
-}
-
-interface V1Status {
-  known: boolean;
-  percent: number;
-  done: number;
-  total: number;
-  complete: boolean;
-  rev: string;
-  rev_as_of: string;
-  last_checked_at: string;
-  rate_limited_until: string;
-  owner_name?: string;
-  // Everything below was added to wire.Status after v1 shipped, so an old
-  // server's atbackupStatus JSON simply omits these — mapV1Status treats a
-  // missing field the same as its "not yet known" wire value (false/null/0).
-  owner_did?: string;
-  rotation_key_confirmed?: boolean;
-  car_size?: number;
-  blob_bytes?: number;
-  blob_count?: number;
-  record_count?: number;
-  last_change_at?: string;
-  last_change_collection?: string;
-  last_change_operation?: string;
-  last_change_rkey?: string;
-  last_change_record?: Record;
-}
-
-/** Maps v1's snake_case wire.Status JSON (whatever atbackupStatus returns)
- * into types.ts's StatusSnapshot — shared by login's initialStatus and
- * Session.status() so the two never drift apart. Fields an old server never
- * sent parse to their honest "not yet known" default (false/null/0), same
- * as a current server would report before anything has landed. */
-function mapV1Status(raw: V1Status): StatusSnapshot {
-  return {
-    known: raw.known,
-    percent: raw.percent,
-    done: raw.done,
-    total: raw.total,
-    complete: raw.complete,
-    rev: raw.rev,
-    revAsOf: parseMaybeTime(raw.rev_as_of),
-    lastCheckedAt: parseMaybeTime(raw.last_checked_at),
-    rateLimitedUntil: parseMaybeTime(raw.rate_limited_until),
-    ownerName: raw.owner_name || undefined,
-    ownerDid: raw.owner_did || undefined,
-    rotationKeyConfirmed: raw.rotation_key_confirmed ?? false,
-    carSize: raw.car_size ?? 0,
-    blobBytes: raw.blob_bytes ?? 0,
-    blobCount: raw.blob_count ?? 0,
-    recordCount: raw.record_count ?? 0,
-    lastChangeAt: parseMaybeTime(raw.last_change_at),
-    lastChangeCollection: raw.last_change_collection || undefined,
-    lastChangeOperation: raw.last_change_operation || undefined,
-    lastChangeRKey: raw.last_change_rkey || undefined,
-    lastChangeRecord: raw.last_change_record || undefined,
-  };
-}
-
-interface V1CheckReport {
-  rev: string;
-  asOf: string;
-  records: number;
-  blocks: number;
-  carBytes: number;
-  blobsVerified: number;
-  blobBytes: number;
-  absentBlobs?: string[];
-  unfetchableBlobs?: string[];
-  complete: boolean;
-  authentic: TriState;
-  upToDate: TriState & { liveRev?: string; liveAsOf?: string };
-  ownerName?: string;
-}
-
-async function resolveHandle(handle: string): Promise<{ did: string; handle: string }> {
-  let resp: Response;
-  try {
-    resp = await fetch(
-      `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
-    );
-  } catch (err) {
-    throw new BridgeError("HANDLE_UNRESOLVED", `Couldn't resolve ${handle}.`, { cause: err });
-  }
-  if (!resp.ok) {
-    throw new BridgeError("HANDLE_UNRESOLVED", `Couldn't resolve ${handle}.`);
-  }
-  const body = (await resp.json()) as { did?: string };
-  if (!body.did) {
-    throw new BridgeError("HANDLE_UNRESOLVED", `Couldn't resolve ${handle}.`);
-  }
-  return { did: body.did, handle };
-}
-
-/** Builds a BackupistGlobal over `globals` (the real `window`/`globalThis`
- * by default — pass a stub in tests). */
-export function createV1Bridge(globals: V1Globals = globalThis as unknown as V1Globals): BackupistGlobal {
-  return {
-    apiVersion: 2,
-
-    resolveHandle,
-
-    async login(opts) {
-      if (opts.signal?.aborted) throw new BridgeError("CANCELLED", "Cancelled.");
-
-      let did = opts.did;
-      if (!did) {
-        if (!opts.handle) {
-          throw new BridgeError("HANDLE_UNRESOLVED", "A handle or DID is required to log in.");
-        }
-        did = (await resolveHandle(opts.handle)).did;
-      }
-
-      // v1 has no standalone "authorize" call; atbackupStatus is the
-      // cheapest request that both proves the (did, phrase) pair is valid
-      // and, rather than being discarded, becomes the session's
-      // initialStatus — the same one-shot round trip the v2 engine's
-      // login() spends on this (see main_js.go's newV2Session).
-      let statusJSON: string;
-      try {
-        statusJSON = await globals.atbackupStatus(did, opts.phrase);
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-      const initialStatus = mapV1Status(JSON.parse(statusJSON) as V1Status);
-
-      return makeV1Session(globals, did, opts.phrase, opts.handle, initialStatus);
-    },
-
-    async checkRotationKey() {
-      throw new BridgeError(
-        "UNKNOWN",
-        "Checking a rotation key on its own isn't supported by this build. Supply it to fetchBackup instead, which checks it as part of downloading.",
-      );
-    },
-
-    async enrollPrepare() {
-      throw enrollUnsupported();
-    },
-
-    async enrollComplete() {
-      throw enrollUnsupported();
-    },
-
-    enrollCancel() {
-      // Nothing to cancel: enrollPrepare always rejects before starting
-      // anything that would need cancelling.
-    },
-  };
-}
-
-function enrollUnsupported(): BridgeError {
-  return new BridgeError(
-    "UNKNOWN",
-    "This build can't enroll a rotation key from the browser yet. Run `backupist enroll` from the command line, against the account's current PDS, then come back to restore.",
-  );
-}
-
-function makeV1Session(
-  globals: V1Globals,
-  did: string,
-  phrase: string,
-  handle: string | undefined,
-  initialStatus: StatusSnapshot,
-): Session {
-  return {
-    did,
-    handle,
-    initialStatus,
-
-    watch(onEvent, opts) {
-      // v1's atbackupWatch has no cancellation of its own: the promise it
-      // returns only resolves once the server-side stream ends. "Stopping"
-      // here means dropping further events on the floor, not tearing down
-      // the underlying iroh connection — the connection closes on its own
-      // once the server stops sending (or the tab is torn down).
-      let dropped = false;
-      opts?.signal?.addEventListener("abort", () => {
-        dropped = true;
-      });
-
-      globals.atbackupWatch(did, phrase, (eventJSON: string) => {
-        if (dropped) return;
-        try {
-          onEvent(JSON.parse(eventJSON) as WatchEvent);
-        } catch {
-          // A malformed frame is dropped, not thrown: WatchEvent's own
-          // tolerance contract (state/reduce.ts) already assumes callers
-          // may see odd data and must not break on it.
-        }
-      }).catch(() => {
-        // Rejects only if connecting/authorizing failed, which login()
-        // already exercised via atbackupStatus above — vanishingly
-        // unlikely here. There's no onEvent-shaped way to surface a
-        // stream-level failure, so it's swallowed rather than thrown from
-        // a function that already returned synchronously.
-      });
-
-      return {
-        stop() {
-          dropped = true;
-        },
-      };
-    },
-
-    async status(): Promise {
-      let json: string;
-      try {
-        json = await globals.atbackupStatus(did, phrase);
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-      return mapV1Status(JSON.parse(json) as V1Status);
-    },
-
-    async logs(): Promise {
-      let json: string;
-      try {
-        json = await globals.atbackupLogs(did, phrase);
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-      return JSON.parse(json) as LogLine[];
-    },
-
-    async check(opts): Promise {
-      opts?.onProgress?.({ phase: "connect", bytesDone: 0, bytesTotal: 0, blobsDone: 0, blobsTotal: 0 });
-      let json: string;
-      try {
-        json = await globals.atbackupCheck(did, phrase);
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-      const raw = JSON.parse(json) as V1CheckReport;
-      opts?.onProgress?.({
-        phase: "verify",
-        bytesDone: raw.carBytes,
-        bytesTotal: raw.carBytes,
-        blobsDone: raw.blobsVerified,
-        blobsTotal: raw.blobsVerified,
-      });
-      return {
-        consistency: {
-          complete: raw.complete,
-          records: raw.records,
-          blocks: raw.blocks,
-          rev: raw.rev,
-          asOf: normalizeMaybeIso(raw.asOf),
-          carBytes: raw.carBytes,
-          blobsVerified: raw.blobsVerified,
-          blobBytes: raw.blobBytes,
-          absentBlobs: raw.absentBlobs ?? [],
-          unfetchableBlobs: raw.unfetchableBlobs ?? [],
-        },
-        authentic: raw.authentic,
-        upToDate: {
-          checked: raw.upToDate.checked,
-          ok: raw.upToDate.ok,
-          error: raw.upToDate.error,
-          liveRev: raw.upToDate.liveRev,
-          liveAsOf: normalizeMaybeIso(raw.upToDate.liveAsOf),
-        },
-        ownerName: raw.ownerName || undefined,
-      };
-    },
-
-    async fetchBackup(opts): Promise {
-      // v1's atbackupFetch reports nothing until it resolves or rejects —
-      // there is no incremental progress to relay. One indeterminate
-      // "started" tick is emitted so a progress UI has something to show
-      // rather than sitting frozen at 0 for the whole download.
-      opts?.onProgress?.({ phase: "connect", bytesDone: 0, bytesTotal: 0, blobsDone: 0, blobsTotal: 0 });
-
-      let summary: string;
-      try {
-        summary = opts?.rotationKey
-          ? await globals.atbackupFetch(did, phrase, opts.rotationKey)
-          : await globals.atbackupFetch(did, phrase);
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-
-      const result = parseFetchSummary(summary);
-      opts?.onProgress?.({
-        phase: "verify",
-        bytesDone: result.carBytes,
-        bytesTotal: result.carBytes,
-        blobsDone: result.blobCount,
-        blobsTotal: result.blobCount,
-      });
-      return result;
-    },
-
-    async migrate(cfg: MigrateConfig): Promise {
-      if (cfg.resume) {
-        throw new BridgeError("UNKNOWN", "Resuming a migration isn't supported by this build; run it from the start.");
-      }
-      if (cfg.signal?.aborted) throw new BridgeError("CANCELLED", "Cancelled.");
-
-      cfg.onProgress?.({ phase: "validate-backup", done: 0, total: 1 });
-
-      let raw: string;
-      try {
-        raw = await globals.atbackupMigrate({
-          newPdsUrl: cfg.newPdsUrl,
-          handle: cfg.handle,
-          email: cfg.email,
-          password: cfg.password,
-          serviceAuthToken: cfg.serviceAuthToken ?? "",
-          verificationCode: cfg.verificationCode ?? "",
-          stopBeforePlc: cfg.stopBeforePlc ?? false,
-        });
-      } catch (err) {
-        throw toBridgeError(err);
-      }
-
-      cfg.onProgress?.({ phase: cfg.stopBeforePlc ? "staged" : "done", done: 1, total: 1 });
-      return { staged: raw.startsWith("STAGED"), review: raw };
-    },
-
-    async confirmRotationKey(): Promise {
-      throw new BridgeError(
-        "UNKNOWN",
-        "This backup server is too old to confirm a rotation key online. Update the backup server, then try again.",
-      );
-    },
-
-    close() {
-      // v1 keeps no client-side session state beyond (did, phrase), which
-      // this closure already holds by value; nothing to release.
-    },
-  };
-}
-
-const FETCH_SUMMARY_RE =
-  /^VERIFIED rev=(\S+) car=(\d+)B blobs=(\d+)\((\d+)B\)(?:; server: (.*?))?(?:; rotation key (\S+) AUTHORIZED)?$/;
-
-/** Parses doFetch's summary string (main_js.go) — the only thing v1's
- * atbackupFetch gives back — into the structured FetchResult v2 promises.
- * Falls back to a minimal result (just the raw summary as `rev`) if the
- * string doesn't match the expected shape, rather than throwing: a
- * successfully-resolved fetch shouldn't become an error just because this
- * adapter's regex is stale. */
-function parseFetchSummary(summary: string): FetchResult {
-  const m = FETCH_SUMMARY_RE.exec(summary.trim());
-  if (!m) {
-    return { rev: summary, carBytes: 0, blobCount: 0, blobBytes: 0 };
-  }
-  const [, rev, carBytes, blobCount, blobBytes, ownerName, rotationKeyDidKey] = m;
-  return {
-    rev,
-    carBytes: Number(carBytes),
-    blobCount: Number(blobCount),
-    blobBytes: Number(blobBytes),
-    ownerName: ownerName || undefined,
-    ...(rotationKeyDidKey ? { rotationKey: { didKey: rotationKeyDidKey, authorized: true as const } } : {}),
-  };
-}
-
-function normalizeMaybeIso(iso: string | undefined): string | null {
-  return iso && !iso.startsWith("0001-") ? iso : null;
-}
diff --git a/web/src/lib/state/checkReport.ts b/web/src/lib/state/checkReport.ts
index 4a1d288..068c078 100644
--- a/web/src/lib/state/checkReport.ts
+++ b/web/src/lib/state/checkReport.ts
@@ -1,7 +1,6 @@
-// Pure shaping of a CheckReport into display lines — the DOM-free
-// re-implementation of web/legacy-test.html's renderConsistency/
-// renderFreshness. Icons/colour are the caller's job (via `ok`/`tone`);
-// this module only decides what the sentences say.
+// Pure shaping of a CheckReport into display lines, DOM-free. Icons/colour
+// are the caller's job (via `ok`/`tone`); this module only decides what the
+// sentences say.
 import { formatWhen, parseMaybeTime } from "../format/time";
 import type { CheckReport } from "../bridge/types";
 
@@ -28,8 +27,7 @@ export interface FreshnessLine {
 }
 
 /**
- * The online authenticity + up-to-date verdicts, as the old page's five
- * branches (web/legacy-test.html's renderFreshness):
+ * The online authenticity + up-to-date verdicts, as five branches:
  *
  *  1. Couldn't even check authenticity (PLC unreachable) — one warn line.
  *  2. Checked, and it's NOT authentic — one danger line.
diff --git a/web/src/lib/state/session.svelte.ts b/web/src/lib/state/session.svelte.ts
index bdadc06..e906ceb 100644
--- a/web/src/lib/state/session.svelte.ts
+++ b/web/src/lib/state/session.svelte.ts
@@ -3,7 +3,6 @@
 // outside the component tree so navigating between views never drops the
 // connection or an in-flight operation.
 import { loadWasm } from "../bridge/loader";
-import { createV1Bridge } from "../bridge/v1-adapter";
 import { createMockBridge, type ScenarioName } from "../bridge/mock";
 import { toBridgeError, type BackupistGlobal, type BridgeError, type Session } from "../bridge/types";
 import { initialView, reduceEvent, seedView, type BackupView } from "./reduce";
@@ -44,8 +43,10 @@ class AppState {
 
   /** Called once from App.svelte. Picks the engine: a `?mock=`
    * URL means the scripted mock (no wasm at all — pure UI development);
-   * otherwise the wasm is loaded and wrapped in the v1 adapter until the
-   * real v2 build exposes `globalThis.Backupist` itself. */
+   * otherwise the wasm is loaded and its `globalThis.Backupist` is used
+   * directly. A wasm build that loads but doesn't expose `Backupist` is a
+   * broken build, not something to fall back from — it fails the same way
+   * a load error does. */
   start(): void {
     if (this.started) return;
     this.started = true;
@@ -61,8 +62,15 @@ class AppState {
       this.wasm = { kind: "loading", percent: total > 0 ? Math.round((loaded / total) * 100) : 0 };
     })
       .then(() => {
-        const v2 = (globalThis as { Backupist?: BackupistGlobal }).Backupist;
-        this.engine = v2?.apiVersion === 2 ? v2 : createV1Bridge();
+        const engine = (globalThis as { Backupist?: BackupistGlobal }).Backupist;
+        if (typeof engine !== "object" || engine === null) {
+          this.wasm = {
+            kind: "failed",
+            reason: "wasm build loaded but didn't expose globalThis.Backupist (broken build)",
+          };
+          return;
+        }
+        this.engine = engine;
         this.wasm = { kind: "ready" };
       })
       .catch((err: unknown) => {