From 506acd8c78505629634293aa34f56d82d41b994c Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:04:47 +0100 Subject: [PATCH 01/16] plans: connection-gated auth + local-first path docs --- ...nection-gated-auth-and-auth-unification.md | 81 ++++++++++ ...-07-06-hub-client-auth-unification-pkce.md | 116 ++++++++++++++ ...hub-client-connection-gated-local-first.md | 135 ++++++++++++++++ ...07-06-hub-client-local-project-adoption.md | 88 +++++++++++ ...7-06-hub-server-minted-sliding-sessions.md | 147 ++++++++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md create mode 100644 claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md create mode 100644 claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md create mode 100644 claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md create mode 100644 claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md diff --git a/claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md b/claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md new file mode 100644 index 000000000..f48c78b6a --- /dev/null +++ b/claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md @@ -0,0 +1,81 @@ +# Auth reshape — overview & path (index) + +**Status:** proposed (index) +**Date:** 2026-07-06 + +This is a **thin index**. The work is decomposed into **three self-contained, +independent plans** (plus a deferred adoption follow-on to plan 1); the substance +lives in each. This doc only records the map, the sequencing, and the +cross-cutting facts that let them stay independent. + +## The three plans + +1. **`2026-07-06-hub-client-connection-gated-local-first.md`** — Connection-gated + auth + local-first documents. The SPA opens with no login gate; documents live + in browser IndexedDB; auth is required only on "Connect to a hub." **v1** ships + local-first + gate removal + a "Connect to a hub" action that opens/creates + **hub-side** projects. **Publishing an existing local project up to a hub + (adoption)** — the actor-switch + sync-up — is a **deferred fast-follow** in + its own plan file (`2026-07-06-hub-client-local-project-adoption.md`), gated + on the D1 durability fix (`bd-10bdjmjb`). *Pure hub-client / sync-client work — + no server-auth changes.* + +2. **`2026-07-06-hub-client-auth-unification-pkce.md`** — Unify hub-client ↔ + hub-mcp auth on Authorization Code + PKCE. Migrate the SPA from the GIS button + to a **public** PKCE client that obtains a Google ID token and hands it to the + existing server callback (**pattern (i)** — no hub token-exchange). **v1 builds + the SPA provider standalone; extracting shared OAuth-config + PKCE primitives + with hub-mcp is deferred** (only if duplication proves real). + +3. **`2026-07-06-hub-server-minted-sliding-sessions.md`** (`bd-ey6jg70f`) — + Server-minted sliding sessions. Validate the Google token once at login, then + mint a hub-signed, compact, HttpOnly cookie with sliding expiry — decoupling + session lifetime from Google's 1 h token and from Google One-Tap. *Adds the + hub's first token-minting capability.* **v1 uses a single session secret; + `kid`/rotation and a revocable store are deferred.** + +## The path (sequencing) + +- **Plan 1 ships first** (user decision) — it's self-contained and delivers the + local-first value with zero server-auth risk. Its **adoption follow-on** + (publish a local project to a hub) lands afterward, once D1 (`bd-10bdjmjb`) + is fixed — separate plan file, same Epic 1. +- **Plans 2 and 3 are independent follow-ons**, either order. Two 2026-07-06 + investigations confirmed plan 2 (PKCE) is **orthogonal** to plan 3 (sessions): + pattern (i) keeps the browser a token *provider*, so no hub minting is needed. +- **One soft dependency:** plan 2's "retire Google One-Tap renewal" phase (B2) + needs a durable renewal path → sequence it after plan 3, or keep One-Tap as the + interim. Everything else is decoupled. **No hard `blocks` between the plans.** +- Plan 3 also has **standalone production value** (it structurally fixes the + closed-but-only-tactically-mitigated bug `bd-3o8zmz46` and the >3800-byte + cookie-drop risk), so it can be prioritized early if desired. +- **Standalone (in no epic):** the Bearer-on-`/auth/actor`+`/auth/me` fix + (`bd-3g0aijb3`) is a small independent server-side bug — a **standalone + strand**, not a phase of plan 2 or plan 3. Fix it whenever; it gates nothing. + +## Cross-cutting realities (why the split is clean) + +1. **A browser is a public OAuth client** — it cannot bind a loopback listener, + use an OS keychain, or hold a `client_secret`; those hub-mcp mechanisms are + inherently native-only (RFC 8252). Unification is therefore at the IdP / + auth-code+PKCE / shared-primitives level, not identical transport/storage. The + hub *already* bridges the SPA and MCP client IDs via a shared JWKS + audience + allowlist. *(Owned by plan 2.)* +2. **Automerge changes are immutable** — you can redirect *future* changes to a + new actor but never re-attribute *past* ones; reconciliation is the + display-layer `identities` map. This is why the local→connected transition is + a *forward actor switch + display bridge*, never a rewrite. *(Owned by the + deferred adoption follow-on `2026-07-06-hub-client-local-project-adoption.md`; + plan 1 v1 keeps the local actor and never switches.)* +3. **The hub is a stateless validator today** — the cookie value *is* Google's + ID token; the hub mints/signs nothing and has no session store. Plan 3 is what + changes that (adds minting; a session store only in its deferred revocable + variant). Plans 1 and 2 leave it stateless. *(Owned by plan 3.)* + +## Shared strand map + +Three epics, no hard cross-epic `blocks`; phase sub-strands filed when each epic kicks off: +- **Epic 1 — `bd-o3if4hrm`** (epic, p1) → **v1: A0–A4 + A7v1** (plan 1); **deferred adoption: A5, A6, A7adopt** (documented in `2026-07-06-hub-client-local-project-adoption.md`; `conditional-blocks` on `bd-10bdjmjb` until D1 lands). Related: `bd-10bdjmjb` (D1 — gates adoption only), `bd-3nzyd` (E2E 401 tests), `bd-qxgoti2b` (Epic 2). +- **Epic 2 — `bd-qxgoti2b`** (epic, p2) → **B1, B2, B4**; **B0 deferred** (shared package, only if duplication proves real). Related: `bd-cmp48`/`bd-81cfshmw` (hub-mcp reference), `bd-ra5ypj3s` (client registration), `bd-ey6jg70f` (B2 soft dep). +- **Epic 3 — `bd-ey6jg70f`** (epic, p2) → **C0–C4, C6, C7**; **C5 (revocable store) + C5b (`kid`/rotation) deferred**. Related: `bd-3o8zmz46` (root-cause bug it fixes). +- **Standalone (not in any epic):** `bd-3g0aijb3` — Bearer on `/auth/actor`+`/auth/me`, a small independent server-side fix. diff --git a/claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md b/claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md new file mode 100644 index 000000000..5747f7a07 --- /dev/null +++ b/claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md @@ -0,0 +1,116 @@ +# hub-client ↔ hub-mcp auth unification (SPA → Authorization Code + PKCE) + +**Status:** proposed +**Date:** 2026-07-06 +**Part of:** the auth-reshape path — see the umbrella index `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. Independent of the other two plans (connection-gated local-first; server-minted sliding sessions). + +## Overview + +Migrate the SPA from the Google Identity Services (GIS) button to **Authorization +Code + PKCE** as a **public client**. The browser obtains a Google ID token and +hands it to the *existing* server callback — **no hub token-exchange, no new +server minting/session capability**. The OAuth-config + PKCE primitives *may +later* be extracted into a shared module with hub-mcp, but **v1 builds the SPA +provider standalone** (inlining the small helpers); the shared package is +deferred (see B0) until duplication proves real. + +### Key reality that shapes this plan +**A browser is a public OAuth client.** It cannot bind a `127.0.0.1` loopback +listener, cannot use an OS keychain, and must not hold a `client_secret`. The +hub-mcp flow's loopback + `@napi-rs/keyring` + confidential-client refresh are +**inherently native-only** (RFC 8252). So "both use PKCE + loopback + keychain" +is not literally achievable. What genuinely unifies (and *already* does, at the +hub) is the **IdP (Google) + shared JWKS validation + an audience allowlist +explicitly built "to share one hub instance between the SPA and quarto-hub-mcp"** +(`crates/quarto-hub/src/auth.rs:129-131`). This plan unifies at the IdP / +auth-code+PKCE / shared-primitives level; the native-only transport/storage +stays divergent by necessity — that is correct, not accidental drift. + +## Current architecture (map for implementers) + +### The SPA auth seam (the extension point) +- `hub-client/src/auth/AuthProvider.tsx` — IdP-agnostic interface consumed via `useAuthProvider`. The seam comment (`:58-66`) already anticipates "a future Code+PKCE provider" whose `loginUri` is "the redirect URI the SPA registers." A `noopAuthProvider` handles auth-disabled builds. +- `hub-client/src/auth/GoogleAuthProvider.tsx` — the only file naming GIS APIs (``, `useGoogleOneTapLogin`, `googleLogout`). This is one implementation behind the interface; a PKCE provider is a sibling. +- Credential sink already exists: silent renewal → `onCredential(jwt)` (`GoogleAuthProvider.tsx:37-54`, `useAuth.ts:119`) → `refreshToken()` → `POST /auth/refresh` (`hub-client/src/services/authService.ts:109-120`). A PKCE provider drops into the same seam and feeds the same id_token to the same endpoint. (Design rationale: `claude-notes/plans/2026-05-20-auth-provider-interface.md:221-224`.) + +### Server connection auth (what the browser talks to) +- Stateless OIDC resource server: validates a Google ID-token JWT locally against cached JWKS. **No session store, no hub-minted JWT** — the cookie value *is* Google's token (1 h `exp`, `AUTH_COOKIE_MAX_AGE=3600`). The hub has **zero OAuth-client capability**: no `client_secret`, no `oauth2`/`openidconnect` crate, no token-endpoint call (`Cargo.toml:29-42`; grep for `client_secret|token_endpoint|authorization_code` in `crates/quarto-hub/src/` is empty). +- Credential accepted as `quarto_hub_token` cookie (browser) **or** `Authorization: Bearer` (MCP); both present → 400; neither → 401 (`server.rs:901-950`, `extract_credential` `:212-247`). +- Endpoints: `/auth/callback` (Google form_post → validate → cookie, `:701-729`), `/auth/refresh` (accepts a fresh JWT → validate → re-set cookie, `:844-879`), `/auth/me`, `/auth/actor`, `/auth/logout`, `/ws`, `/health`, `/api/*` (`:1048-1081`). Both `/auth/callback` and `/auth/refresh` are the *receiving* half of a BFF — "receive an already-minted token → validate → cookie it." +- A **stateless** `CallbackCsrfMode::OidcState` variant exists but is a fail-safe stub (`auth.rs:644-664`, `validate_callback_csrf` returns `false` for it at `server.rs:684-688`) — the standard `state`-cookie CSRF path a redirect flow would use. +- Security invariants to preserve: CSRF via `X-Requested-With` (`server.rs:328-338`), WS-origin check (`:345-367`). +- Browser = Web-app Google client (`VITE_GOOGLE_CLIENT_ID` == server `--oidc-client-id`); MCP = separate Desktop-app Google client; hub bridges the two `aud` values via `--additional-audiences` (`auth.rs:129-131`, applied `:599-603`). + +### hub-mcp reference (the flow we align to) +- hub-mcp uses Authorization Code + PKCE (S256) + loopback (RFC 8252), but as a **confidential** Desktop-app client: it retains a `client_secret` for the token exchange + refresh grant, and stores tokens in the OS keyring. Its `exchangeCode` (`ts-packages/quarto-hub-mcp/src/auth/auth-tools.ts:440-478`) uses `ClientSecretPost(clientSecret)` — a browser public client can replicate **none** of this. The reusable parts are `oauth-config.ts` (issuer resolution, OIDC discovery) and `pkce.ts` (S256 verifier/challenge/state via `oauth4webapi`) — pure IdP plumbing, portable to the browser. + +## Design + +### What unifies vs what stays divergent +- **Unifies:** Google as IdP; Authorization Code + PKCE (S256); the hub's shared JWKS validation + audience allowlist (already present); and — optionally, later — shared OAuth-config + PKCE primitive modules (see B0, deferred). +- **Stays divergent (by necessity):** the SPA is a **public** client → origin `redirect_uri` (not loopback), token lands in the server-set HttpOnly cookie (not an OS keychain), no `client_secret`, no locally-stored refresh grant. hub-mcp keeps loopback + keyring + confidential Desktop-app client. + +### Flow shape — pattern (i), public-client / ID-token (recommended) +The browser runs authorize + PKCE + `state`/nonce and obtains a **Google ID +token**, which it hands to the *existing* `/auth/refresh` (or `/auth/callback`) +that validates + cookies it — today's model, different acquisition. This keeps +this plan **independent of any server minting** — no dependency on the +sliding-sessions plan. If the redirect is routed through a hub HTTP callback +(rather than landing in the SPA), implement the existing **stateless** +`OidcState` CSRF stub — this is *not* a session store. + +**Rejected — pattern (ii), BFF** (hub exchanges the auth code server-side): gives +the hub a brand-new OAuth-*client* role it entirely lacks today (an `oauth2` +client, the Web-app `client_secret`, a token-endpoint call) and pulls +refresh-token + session handling in. Not entailed by "the browser uses PKCE"; +adds surface. Do not adopt without a specific driver. **Decide up front** — it +dictates the Google client registration. + +## Phases (TDD-first) + +- [ ] **B1 — SPA Authorization Code + PKCE provider (pattern i).** New `AuthProvider` implementation: public client, origin `redirect_uri`, PKCE, `state`/nonce; obtains a Google ID token and feeds it to the existing `/auth/refresh` (or `/auth/callback`). **Built standalone — inline the small PKCE / oauth-config helpers (browser Web Crypto); do not block on a shared package (B0, deferred).** Register the Google Web-application `redirect_uri`. Optionally implement the `OidcState` CSRF stub if routing through a hub callback. Tests: full redirect round-trip against a mock IdP; `state`/PKCE verification; `GoogleAuthProvider` still selectable behind a flag during migration. +- [ ] **B2 — Retire GIS/One-Tap renewal.** Replace `useSilentRenewal` (One-Tap) with the chosen renewal path; make `GoogleAuthProvider` legacy/removable. **Sequencing:** the durable renewal path is the sliding-sessions plan — sequence B2 after that plan lands, or keep One-Tap as the interim. Tests: refresh works where One-Tap is blocked (FedCM/3p-cookie). +- [ ] **B4 — E2E verification + docs.** Both `q2 mcp` and a real browser authenticate against one hub via the unified path; both obtain the correct per-project actor. Record invocations + observed output. Docs. + +### Deferred / de-scoped + +- [ ] **B0 (deferred) — Extract shared OAuth primitives.** Only once B1 proves the browser needs the same `pkce.ts` (30 lines) / `oauth-config.ts` (155 lines) as the hub-mcp Node bundle *and* the duplication is real. The two runtimes use different crypto backends (browser Web Crypto vs Node), so a cross-runtime package boundary is not obviously worth it up front — extract if duplication proves painful, otherwise leave inlined. +- **B3 — out of scope (standalone strand).** The Bearer-on-`/auth/actor`+`/auth/me` fix (`bd-3g0aijb3`) is a small, independent server-side bug that gates nothing; it is tracked as its **own standalone strand**, not a phase of this epic. (It fixes MCP sessions silently falling back to random actors.) + +## Non-goals +- The browser will **not** use a loopback listener, an OS keychain, or a `client_secret`. That is a property of public clients, not a gap to close. +- Does **not** introduce hub token-minting or sessions (that's the sliding-sessions plan). Pattern (i) keeps the hub a pure validator. + +## Relationship to the other plans +- **Independent of the connection-gated local-first plan.** That plan's "Connect to a hub" action triggers whatever browser flow exists — GIS today, this plan's PKCE flow after it lands. +- **Soft dependency on server-minted sliding sessions:** B2 (retiring One-Tap) needs a durable renewal path. Either sequence B2 after the sessions plan, or keep One-Tap as the interim. B1 has no such dependency. + +## Risks & open questions +- **B1 flow-shape decision (pattern i vs BFF):** adopt pattern (i) to keep this plan independent of server minting; BFF re-couples everything. Decide before registering the Google client. +- **Client-id / redirect-uri registration (B1):** requires a Google Web-app client `redirect_uri` registration change; coordinate with whoever owns the quarto-hub.com OAuth clients (see `bd-ra5ypj3s`). +- **Migration overlap:** keep `GoogleAuthProvider` selectable behind a flag until the PKCE provider is proven in production. + +## Braid strand structure +- **Epic `bd-qxgoti2b`** (epic, p2, open). Sub-strands **B1, B2, B4** (parent-child). **B0 deferred** (own strand, opened only if duplication proves real); **B3 removed** — `bd-3g0aijb3` is tracked standalone. +- Related links in place: `bd-3g0aijb3` (standalone Bearer fix — related, no longer a phase), `bd-cmp48` / `bd-81cfshmw` (hub-mcp reference), `bd-ra5ypj3s` (client registration), `bd-ey6jg70f` (B2 soft dependency). No hard `blocks` to the other two plans. + +## References +### Plans +- `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md` — umbrella / path. +- `claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md` — the durable renewal path B2 depends on. +- `claude-notes/plans/2026-05-20-auth-provider-interface.md` — the `AuthProvider` seam (done). +- `claude-notes/plans/2026-02-26-httponly-cookie-auth.md` — cookie storage (done). +- `claude-notes/plans/2026-05-28-hub-mcp-loopback-pkce.md` — hub-mcp current auth (loopback + PKCE, confidential Desktop-app client). +- `claude-notes/plans/2026-06-11-q2-mcp-hub-auth.md` — `q2 mcp` launcher (inherits TS auth). +- `claude-notes/plans/2026-05-05-hub-mcp-device-flow-implementation.md` — superseded device flow. + +### Strands +- `bd-3g0aijb3` — Bearer on `/auth/actor` and `/auth/me` (standalone; not a phase of this epic). +- `bd-cmp48`, `bd-81cfshmw` — hub-mcp auth (reference). +- `bd-ra5ypj3s` — Google client registration issue (B1). +- `bd-ey6jg70f` — server-minted sliding sessions (B2 soft dependency; own plan). + +### Key files +- SPA auth seam: `hub-client/src/auth/{AuthProvider,GoogleAuthProvider,MockAuthProvider}.tsx`, `hub-client/src/hooks/useAuth.ts`, `hub-client/src/services/authService.ts`, `hub-client/src/main.tsx` (provider selection). +- Server auth: `crates/quarto-hub/src/server.rs` (212-247, 328-338, 345-367, 662-729, 844-879, 901-950, 1048-1081), `crates/quarto-hub/src/auth.rs` (129-131, 599-603, 644-664). +- hub-mcp auth (reference): `ts-packages/quarto-hub-mcp/src/auth/{oauth-config,pkce,loopback,browser,auth-tools,refresh-manager,credential-store}.ts`, `crates/quarto-mcp-launcher/src/{lib,defaults}.rs`. diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md new file mode 100644 index 000000000..75657ff82 --- /dev/null +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -0,0 +1,135 @@ +# hub-client: connection-gated auth + local-first documents + +**Status:** proposed +**Date:** 2026-07-06 +**Part of:** the auth-reshape path — see the umbrella index `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. Independent of the two sibling plans (Part B PKCE unification; server-minted sliding sessions). Publishing a local project to a hub is the separate adoption follow-on `claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md`. + +## Overview + +The SPA opens straight into a usable app (project selector + editor) with **no +login gate**. Users create and edit Automerge documents that live entirely in +browser IndexedDB. Authentication is required **only** when the user chooses to +connect to a hub server. + +**v1 scope:** local-first documents + gate removal, and a "Connect to a hub" +action that authenticates and **opens or creates hub-side projects** (today's +connect path, HMAC actor from the first change). Local projects stay local. + +**Publishing an existing local project up to a hub** — the forward actor-switch ++ display-bridge and the sync-up — is a **separate follow-on plan** +(`2026-07-06-hub-client-local-project-adoption.md`), deferred because it is the +only part that hard-depends on the unsolved offline-doc durability problem +(`bd-10bdjmjb`, D1) and carries all the actor-reconciliation risk. v1 delivers +the headline local-first value with **zero dependency on D1**. + +This effort is **pure hub-client / sync-client work — no server-auth changes**, +which is why it ships first. + +## Current architecture (map for implementers) + +> Line numbers are approximate as of 2026-07-06; grep to confirm before editing. + +### Entry gate (the target — small) +- `hub-client/src/App.tsx:82` — `AUTH_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID` (module-scope, build-time). +- `hub-client/src/App.tsx:581-587` — loading gate (spinner). +- `hub-client/src/App.tsx:589-596` — **the entry gate**: `if (AUTH_ENABLED && !auth) return `. Nothing renders until this passes. +- `hub-client/src/App.tsx:85-93` — `useAuth()` on mount calls `GET /auth/me` for the whole app. **Already non-blocking at the hook level** — its mount effect `.catch()`es a 401/network failure and just clears `loading` (`useAuth.ts:81-84`); the only thing that blocks first render is the App.tsx gate above. +- `hub-client/src/App.tsx:400-409` — disconnect-on-auth-loss (ties auth lifecycle to project lifecycle); must be scoped to *connected* projects so a local project is never torn down on auth loss. +- When `VITE_GOOGLE_CLIENT_ID` is unset, `AUTH_ENABLED=false` and both gates are skipped today — but that is a *build/deploy* toggle, not a runtime choice. This plan makes "no gate" the default runtime behavior. + +### Connection-auth surface (already connection-scoped — reuse as-is) +- Ambient `quarto_hub_token` HttpOnly cookie on the same-origin WS upgrade (`ts-packages/quarto-sync-client/src/client.ts:135-144`; the browser attaches it automatically — no token in the WS URL). +- `resolveActorId` → `GET /auth/actor?project=…` per connect (`hub-client/src/services/authService.ts:82-94`, called at `App.tsx:128-131`). +- `useAuthProbe` watchdog, gated on `AUTH_ENABLED && !!auth && !!project && !isOnline` (`App.tsx:126-130`) — its `enabled` guard is part of the `AUTH_ENABLED` reconciliation in A4. + +### Local-first infrastructure (~80% present) +- Document IDs minted **client-side** via `generateAutomergeUrl()` (`client.ts:1529`) — no server needed for identity. +- `IndexedDBStorageAdapter` persists all docs across reloads (`ts-packages/quarto-sync-client/src/storage-adapter.ts:72`). +- `connect` and `createNewProject` already have offline fallbacks (1 ms peer-wait → load/create from IndexedDB) (`client.ts:830-846`, `:1503-1527`). +- A storage-only, no-network Repo is **proven** in the debug harness: `createLocalStorageRepo` = `new Repo({ storage })` (`hub-client/src/debug/services/repo.ts:80`). +- automerge-repo supports attaching a network adapter later (`repo.networkSubsystem.addNetworkAdapter`) — used by the adoption follow-on. + +### Local-first blockers (what this plan must change) +- `hub-client/src/services/projectSetService.ts:191-211` — `createProjectSet` hard-requires a live server (`waitForPeer(repo, 10000)`, throws "Could not reach sync server"). (Note: the sibling `connect` at `:122-183` already degrades gracefully offline — only *creation* is fatal.) +- Every production Repo is built *with* a WS network adapter (`client.ts:820-824`, `:1496-1499`; `projectSetService.ts:128-129`). No `network: []` path in the main app. +- `syncServer` is a mandatory field (`projectStorage.ts:64`, the synced set entry, `App.tsx:507`). + +### Actor-ID mechanics (relevant to both local authorship and hub-project connect) +- Server derivation: `actor = hex(HMAC-SHA256(server_secret, sub || "\0" || project_id))` — stable per `(user, project)`, trusted (derived from verified `sub`), not client-computable (`crates/quarto-hub/src/auth.rs:729-737`). This is the actor a **hub** project uses from its first change (via `resolveActorId` at connect). +- `GET /auth/actor?project=` does **no project validation** (`server.rs:788-790`) — returns the actor for any project id, seen or not. (This is what makes the adoption follow-on possible: the client can derive the stable actor for a locally-minted project the moment the user authenticates.) +- Actor set per-document via `applyActorId(handle, actorId)` = `handle.update(doc => automergeClone(doc, { actor }))` (`client.ts:475-478`), called on every `findDoc` (`client.ts:519`) — so authoring a handle under a chosen actor while preserving history is routine and proven. +- Null actor (auth disabled / offline today) → Automerge assigns a **random actor per handle, re-randomized on every reload** — authorship noise. Nothing persists a local actor today. **A3 fixes this.** +- Authorship maps actor→user via `IndexDocument.identities: Record` (`ts-packages/quarto-automerge-schema/src/index.ts:24-27,58-63`), written by `setIdentity` (`:105-115`). Display falls back to `actor.slice(0,8)` + hashed color when no row exists (`hub-client/src/hooks/useAttribution.ts:69-87`). "Me" = the Automerge actor (`claude-notes/plans/2026-05-25-reactji-authorship-q2-preview.md`). + +### Server connection auth (unchanged by this plan) +The hub validates the ambient cookie on every WS/REST call regardless of what the +SPA renders (`server.rs:901-950`). Removing the client gate does **not** weaken +server enforcement — an auth-required hub simply 401s the WS upgrade, which +becomes the trigger to log in. (Details live in the Part B plan.) + +## Design + +### A. Local-first documents + persisted local actor (v1) +The pre-connect (local, no auth) state a project lives in: +1. Client mints `indexDocId` (`generateAutomergeUrl()`) — already client-side. +2. Client generates a **persisted per-browser local actor** (16 random bytes → 32 hex, a valid Automerge actor id) and stores it in IndexedDB keyed to the local project (A3). *New persistence — nothing stores an actor today.* This replaces the current random-per-reload behavior with a stable local identity. **This is the authorship UX we want:** local edits attribute to one coherent author across reloads instead of a new 8-hex stub each time. +3. SyncClient builds a **storage-only Repo** (no network adapter — new path, mirrors the proven `createLocalStorageRepo`) (A2). +4. All docs are authored under `localActor` via `applyActorId`; write `identities[localActor] = { name: , color }` so local edits attribute coherently (A3). +5. Project recorded in the local list (IndexedDB), `syncServer` unset (A1/A2). + +In v1 the local actor **persists for the life of the local project — it is never +switched.** Switching to the server-trusted HMAC actor when a local project is +*published* to a hub (the forward switch + display bridge) is the **adoption +follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. + +### B. Gate removal & connection UX +- Convert `AUTH_ENABLED` from a *gate* into runtime *capability* state: the app shell + project selector always render. `useAuth()` still probes `/auth/me` on mount to recognize an existing session, but a 401 / network error is non-blocking (already true at the hook level — see the entry-gate map; the render gate is what must go). Must tolerate a backend-less static deploy. +- Add an explicit **"Connect to a hub"** action that: (a) triggers login if there is no session, (b) **opens or creates hub-side projects** (today's connect path, HMAC actor from the first change). *Publishing an existing local project up to the hub is the adoption follow-on — not wired into this action in v1.* + - **Placement (decided 2026-07-15): a header/account-level control**, not a per-project action button. Hub connection is *session-scoped* state (the ambient cookie), so it lives at account level next to the existing sign-in/out affordance: the header shows **"Connect to a hub"** when disconnected and **"Signed in as X ▾ / Sign out"** when connected. The three project-action buttons stay pure project operations — Create/Import default to **local**; hub open/create surfaces only once connected. This deliberately keeps "Connect to a hub" out of the action-buttons row so it does **not** collide with the existing **"Connect to Project"** button (`ProjectSelector.tsx:664-675`), which means the unrelated "join an existing Automerge doc by ID + sync-server URL" flow. + - The header connection control replaces today's `onSignOut`-only header block (`ProjectSelector.tsx:553-565`), which is currently gated on `AUTH_ENABLED`; A4 rewires it to runtime connection state. +- Reconcile the `AUTH_ENABLED`-driven props (`onSignOut`, `authEmail/Name/Picture` on `ProjectSelector`, `App.tsx:657-662`) and the `useAuthProbe` `enabled` guard to runtime auth state. + +## Phases (TDD-first) — v1, no D1 dependency + +- [ ] **A0 — Test scaffolding.** Fixtures for a local-only project (no server); a fake/mock hub for the connect leg (open/create a hub project); extend `MockAuthProvider` usage; E2E harness that boots the SPA with **no** `VITE_GOOGLE_CLIENT_ID` and with an auth-required hub. Write the failing tests for A1–A4 first. +- [ ] **A1 — Local-only project-set creation.** Add a local creation path to `projectSetService` (mint set-doc id client-side, store in IndexedDB, defer sync); make `waitForPeer` non-fatal for local mode. Tests: create a project fully offline; reload and see it listed; no network call made. +- [ ] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* +- [ ] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. +- [ ] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. +- [ ] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. + +> The adoption follow-on (A5 — connect+adopt, A6 — offline-doc durability/D1, A7adopt — E2E) lives in `2026-07-06-hub-client-local-project-adoption.md`, `conditional-blocks` on `bd-10bdjmjb`. + +## Non-goals +- **v1 does not publish an existing local project up to a hub** — that is the adoption follow-on plan (`2026-07-06-hub-client-local-project-adoption.md`), deferred behind D1 (`bd-10bdjmjb`). +- Does **not** change server-side auth enforcement; an auth-required hub still 401s unauthenticated connections. +- Does **not** change how the browser authenticates (that's the Part B plan); "Connect to a hub" triggers whatever browser auth flow exists (GIS today). + +## Risks & open questions +- **Backend-less static deploys:** A4 must keep `/auth/me` failure fully non-blocking end-to-end (the hook already tolerates it; verify the render path against a pure static host). +- **Auth-loss teardown:** the disconnect-on-auth-loss effect (`App.tsx:400-409`) must not tear down a local project; scope it to connected projects in A4. +- ~~**"Connect to a hub" placement:** confirm the action's placement in the project selector UX.~~ **Resolved 2026-07-15:** header/account-level control (session-scoped connection state), not a fourth action button — see Design §B. Avoids the name clash with the existing "Connect to Project" (join-by-doc-ID) button. +- **D1 is out of this plan's critical path** — it gates only the adoption follow-on, tracked in that plan. + +## Braid strand structure +- **Epic `bd-o3if4hrm`** (epic, p1, open). **v1 sub-strands A0–A4 + A7v1** (parent-child). The **adoption group (A5, A6, A7adopt)** stays under the same epic but is documented in the adoption plan and filed `conditional-blocks` on `bd-10bdjmjb` so it stays unready until D1 lands. +- Related links in place: `bd-10bdjmjb` (D1 — gates adoption only), `bd-3nzyd` (E2E 401 tests), `bd-qxgoti2b` (Epic 2). No hard `blocks` to the other two plans. + +## References +### Plans +- `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md` — umbrella / path. +- `claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md` — the adoption follow-on (publish a local project to a hub; deferred behind D1). +- `claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md` — Part B (how the browser authenticates on connect). +- `claude-notes/plans/2026-06-12-sync-client-offline-race.md` — D1 durability. +- `claude-notes/plans/2026-05-25-reactji-authorship-q2-preview.md` — authorship model. +- `claude-notes/plans/2026-05-20-auth-provider-interface.md` — the `AuthProvider` seam (done). + +### Strands +- `bd-10bdjmjb` — offline-fallback race / D1 (adoption follow-on). +- `bd-3nzyd` — E2E preview-iframe 401 tests (test-harness reference). + +### Key files +- SPA gate/auth: `hub-client/src/App.tsx` (82, 400-409, 581-596, 85-93, 657-662), `hub-client/src/hooks/{useAuth,useAuthProbe}.ts`, `hub-client/src/services/authService.ts`, `hub-client/src/auth/{AuthProvider,GoogleAuthProvider}.tsx`. +- Storage/sync/actor: `ts-packages/quarto-sync-client/src/client.ts` (475-497, 519, 803-879, 1481-1552, 820-824), `storage-adapter.ts:72`, `hub-client/src/services/projectSetService.ts` (128-129, 191-211), `hub-client/src/services/projectStorage.ts`, `hub-client/src/debug/services/repo.ts:80`. +- Schema/attribution: `ts-packages/quarto-automerge-schema/src/index.ts` (24-27, 58-63, 105-115), `hub-client/src/hooks/useAttribution.ts:69-87`, `hub-client/src/services/attribution-runs.ts:275-276`. +- Server (read-only context): `crates/quarto-hub/src/auth.rs:729-737` (actor HMAC), `crates/quarto-hub/src/server.rs` (788-807 `/auth/actor`, 901-950 WS auth). diff --git a/claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md b/claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md new file mode 100644 index 000000000..55ee51db6 --- /dev/null +++ b/claude-notes/plans/2026-07-06-hub-client-local-project-adoption.md @@ -0,0 +1,88 @@ +# hub-client: adopt (publish) a local project into a hub + +**Status:** proposed · **deferred** — gated on the D1 offline-doc durability fix (`bd-10bdjmjb`). +**Date:** 2026-07-06 +**Part of:** the auth-reshape path — see the umbrella index `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. This is the **fast-follow** to plan 1 (connection-gated local-first, `claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md`). It builds directly on the local-first foundation plan 1 ships (storage-only Repo, persisted local actor, optional `syncServer`). + +## Overview + +Plan 1 ships local-first documents and a "Connect to a hub" action that +opens/creates **hub-side** projects. This follow-on adds the missing direction: +**publishing an existing *local* project up to a hub** — the forward +actor-switch + display bridge and the sync-up. + +This is a separate, deferred plan because it is the only part that hard-depends +on the unsolved offline-doc durability problem (`bd-10bdjmjb`, D1) and carries +all the actor-reconciliation risk. Plan 1's v1 delivers the headline local-first +value with **zero dependency on D1**; adoption lands once D1 does. + +### Key reality that shapes this plan +**Automerge changes are immutable.** You can redirect *future* changes to a new +actor (routine — `applyActorId` runs on every `findDoc`), but you can never +re-attribute *past* changes. Pre-connect local edits are authored under a local +actor forever; the only reconciliation is the display-layer `identities` map. +This is why the local→connected actor design below is a *forward switch + +display bridge*, never a rewrite. + +## Design: the local→connected actor-ID design + +**Pre-connect (local, no auth)** — the state plan 1 v1 already leaves a local +project in (recap; nothing here is new work for this plan): +1. Client mints `indexDocId` (`generateAutomergeUrl()`) — client-side. +2. A **persisted per-browser local actor** is stored in IndexedDB keyed to the local project (plan 1 A3). +3. The Repo is **storage-only** (no network adapter — plan 1 A2). +4. All docs are authored under `localActor` via `applyActorId`; `identities[localActor] = { name, color }` is written so local edits attribute coherently across reloads (plan 1 A3). +5. Project recorded in the local list (IndexedDB), `syncServer` unset (plan 1 A1/A2). + +**On auth + connect (adoption — the work in this plan):** +6. Auth completes (GIS today; PKCE after Part B) → cookie set. +7. Client calls `GET /auth/actor?project=` → `stableActor` (works despite the server never having seen the project — no project validation: `server.rs:788-790`). +8. `applyActorId(handle, stableActor)` on all handles → **future edits carry the trusted, cross-device-stable HMAC actor.** +9. **Bridge the past (display only):** write `identities[stableActor] = { name: userName, color }` *and* upgrade `identities[localActor]` to the same `{ name: userName, color }`. Because `localActor` was persisted (step 2), this is deterministic. Now pre-connect history and post-connect edits both display as the same human. No change is rewritten. +10. Attach a network adapter to the existing Repo and sync `indexDocId` + all file docs up (A5; requires D1 durability, A6). +11. Record `syncServer` on the project now that it is known. + +**Rejected alternative — keep `localActor` forever (never switch to HMAC):** +simpler (no actor switch, single author), but loses cross-device actor stability +(each browser has its own local actor) and server-trusted attribution +(client-chosen actor is spoofable; the HMAC actor is derived from a verified +`sub`). The hub's whole authorship model is built on the HMAC actor, so we +switch forward and bridge the display. *(Note: "keep `localActor`" is exactly +plan 1 v1's behavior for a local project until it is adopted — the switch + +bridge is what adoption adds.)* + +## Phases (TDD-first) — gated on D1 / `bd-10bdjmjb` + +- [ ] **A5 — Connect + adopt local project.** Adoption action on an existing local project: login if needed → `GET /auth/actor?project=` → `applyActorId(stableActor)` on all handles → write/bridge `identities` (steps 8–9 above) → `addNetworkAdapter` → sync up → record `syncServer`. Tests: local project's future edits carry `stableActor`; pre-connect history + post-connect edits display as one human; content reaches the (fake) hub. +- [ ] **A6 — Offline-doc durability (D1).** Harden offline-created file docs so adoption reliably syncs every doc (coordinate with `bd-10bdjmjb` / `2026-06-12-sync-client-offline-race.md`). Tests: create N files offline, connect, assert all N arrive at the hub. +- [ ] **A7adopt — End-to-end adoption verification.** Real browser: create a project offline with N files, adopt it into a running hub, confirm all N sync + one-human authorship in DevTools/UI. Record steps + observed output per the repo's end-to-end policy. + +## Non-goals +- Does **not** re-attribute past Automerge changes (impossible); adoption bridges attribution at the display layer only. +- Does **not** change server-side auth enforcement; an auth-required hub still 401s unauthenticated connections. +- Does **not** change how the browser authenticates (that's the Part B plan); the adoption action triggers whatever browser auth flow exists (GIS today). + +## Risks & open questions +- **D1 durability (`bd-10bdjmjb`, in progress)** gates this entire plan; it is why adoption is a fast-follow rather than part of plan 1 v1. +- **Local→remote UX:** confirm whether a local project that has been adopted can also be opened read-only when offline after having synced. +- **Actor-reconciliation correctness:** the bridge (step 9) must run before the first post-connect change is observed by other peers, or a collaborator briefly sees the `localActor` history as an unknown author. + +## Braid strand structure +- Part of **Epic `bd-o3if4hrm`** (shared with plan 1). The adoption group — **A5, A6, A7adopt** — is filed as a group `conditional-blocks` on `bd-10bdjmjb`, so it stays unready until D1 durability lands. +- Related: `bd-10bdjmjb` (D1 — gates this plan). + +## References +### Plans +- `claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md` — plan 1 (the v1 foundation this builds on). +- `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md` — umbrella / path. +- `claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md` — Part B (how the browser authenticates on connect). +- `claude-notes/plans/2026-06-12-sync-client-offline-race.md` — D1 durability. +- `claude-notes/plans/2026-05-25-reactji-authorship-q2-preview.md` — authorship model. + +### Strands +- `bd-10bdjmjb` — offline-fallback race / D1 (A6). + +### Key files +- Storage/sync/actor: `ts-packages/quarto-sync-client/src/client.ts` (475-497 `applyActorId`, 519, 1529 `generateAutomergeUrl`), `hub-client/src/services/projectSetService.ts`, `hub-client/src/services/projectStorage.ts`. +- Schema/attribution: `ts-packages/quarto-automerge-schema/src/index.ts` (24-27, 58-63, 105-115 `setIdentity`), `hub-client/src/hooks/useAttribution.ts:69-87`. +- Server (read-only context): `crates/quarto-hub/src/auth.rs:729-737` (actor HMAC), `crates/quarto-hub/src/server.rs` (788-807 `/auth/actor`, no project validation). diff --git a/claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md b/claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md new file mode 100644 index 000000000..1f10e00f2 --- /dev/null +++ b/claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md @@ -0,0 +1,147 @@ +# Hub server-minted sliding sessions + +**Status:** proposed +**Date:** 2026-07-06 +**Strand:** `bd-ey6jg70f` (use as the epic) +**Part of:** the auth-reshape path — see the umbrella index `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. Independent of the other two plans (connection-gated local-first; Part B PKCE unification), except that Part B's "retire One-Tap" phase soft-depends on this one. + +## Overview + +Today the hub is a **stateless OIDC resource server**: the `quarto_hub_token` +HttpOnly cookie's value *is* the Google ID token (1 h `exp`), re-validated +against Google's JWKS on every request. The hub mints/signs nothing. This has +two independent failure modes: + +1. **Renewal depends on Google One-Tap**, which is unavailable in many + environments (FedCM / third-party-cookie policies / unregistered origins → + `gsi/status 403`). When the 1 h token expires and One-Tap can't renew, every + WS upgrade 401s and the SPA presents as permanently offline. This *is* the + root cause of the closed bug `bd-3o8zmz46`, which shipped only a **tactical** + client-side mitigation (surface `exp`, schedule refresh, two-strike probe) + and explicitly deferred the structural fix to this strand. +2. **Large IdP tokens can be silently dropped.** `build_auth_cookie` warns that + a token >3800 bytes may exceed the ~4096-byte cookie limit and be silently + dropped by the browser (`server.rs:292-298`) — presenting, again, as "not + logged in." + +**Goal:** validate the Google token **once** at login, then mint a **hub-signed, +compact, HttpOnly session cookie with sliding expiry**. The hub re-issues the +cookie on activity, so sessions outlive Google's 1 h token without One-Tap, and +the cookie is small. Bearer/JWKS validation (the MCP path) is untouched. + +### Why this is a separate plan (not a Part-B phase) +- **Orthogonal to the SPA→PKCE migration.** PKCE changes how the browser + *acquires* a Google ID token; sliding sessions change what the hub *does with* + it. Neither requires the other (2026-07-06 coupling investigation). +- **Standalone production value.** It fixes `bd-3o8zmz46`'s root cause and the + >3800-byte drop regardless of PKCE or local-first. +- **New security-critical surface.** It adds the hub's **first token-minting + capability** and (in the revocable variant) its **first session store**, on + the credential path — it merits a focused design + security review. + +## Current state (map for implementers) + +### The hub signs nothing today, but the primitive + secret pattern exist +- Validation-only: `jsonwebtoken = "10"` + `axum-jwt-auth = "0.6"` used only for `RemoteJwksDecoder` *decode* (`auth.rs:344`, `:612-618`). `EncodingKey` (the mint primitive) appears **only** in `tests/auth_bearer.rs` (mock IdP), never in production. No `client_secret`, no `oauth2`/`openidconnect` client. +- **HMAC-SHA256 is already in use and shipped:** `sub_to_actor_id_for_project` (`auth.rs:729-737`); deps `hmac`, `sha2`, `rand` already present. +- **Secret lifecycle precedent:** `resolve_server_secret` (`storage.rs:179-197`) resolves a 32-byte secret via env `QUARTO_HUB_SERVER_SECRET` → `hub.json` `server_secret` → auto-generate + persist (mode `0o600`, `storage.rs:115-135`). Cached in `StorageManager.server_secret` (`storage.rs:232,317,339`), surfaced via `ctx.server_secret_bytes()` (`context.rs:388`). **Survives restarts; no rotation mechanism (no `kid`/versioning).** + +### No session store today +- No DB dep (no sqlite/redis/sled/sqlx). Only persistence: `hub.json` (single config blob), `hub.lock` (fs2 lock), and samod `TokioFilesystemStorage` at `/automerge` (document blobs — not queryable, not a session table). Revocation → the hub's **first** session store. + +### Credential validation is centralized (change is additive) +- One extractor: `extract_credential(&HeaderMap)` → `Credential::{Cookie|Bearer}(String)` (`server.rs:212`); both present → **400 conflicting** (`:241-246`). +- One core validator: `authenticate_claims_for_kind(token, kind)` (`context.rs:538`); wrappers `authenticate_claims` (`:526`), `authenticate` (`:511`) delegate here. +- ~6 call sites: `Authenticated` extractor → `:440`; `ws_handler` → `:938`; `auth_me` → `:770` (cookie-only); `auth_actor` → `:801` (cookie-only); `auth_callback` → `:717` (validates *incoming* Google token before cookie-ing); `auth_refresh` → `:868` (validates *new* Google token before re-cookie-ing). +- `CredentialKind` enum (`server.rs:149`) already threaded through audit + CSRF/Origin gating (cookie-only CSRF `:569`, logout CSRF `:820`, WS-Origin `:930`). Bearer is the documented non-browser MCP path, exempt from CSRF/Origin (`:144-147`). +- `AUTH_COOKIE_MAX_AGE = 3600` (`server.rs:133`) hard-binds cookie lifetime to Google's 1 h — sliding sessions decouple this. +- **`ws_handler` validates once at upgrade and never re-checks** (`server.rs:894-900`) — expiry/revocation only takes effect on reconnect. Sliding sessions inherit this trade-off; live-socket revocation is out of scope (would need periodic re-check). + +### Client refresh plumbing partly exists (from `bd-3o8zmz46`) +- `/auth/me` already returns token `exp` (`auth.rs:212-217`, `server.rs:737-739,777`); the SPA reads it and schedules refresh, with a two-strike WS-down `/auth/me` probe and offline-safe error handling (`useAuth.ts`, `useAuthProbe.ts`). Under sliding sessions, only *what mints the cookie* and *what `exp` means* (now sliding) change; the client can rely on **server-side re-issue on activity** and drop the One-Tap dependency. + +## Design decisions + +1. **Token format — hub-signed compact token.** Mint an HS256 JWT (reuse + `jsonwebtoken`'s `EncodingKey::from_secret`) or an equivalent compact + `base64url(payload).hmac` token, signed with a dedicated **session** secret. + Payload: `sub`, the display claims `/auth/me` needs (`email`, `name`, + `picture`), `iat`, sliding `exp`, an optional `kid` (reserved for future + rotation — **absent in v1**, which uses a single secret), and an optional + `sid` (only if/when revocation lands). Distinguish it from Google tokens by + `iss = "quarto-hub"` (route to HMAC verify, not JWKS). Size ≈ 200–400 bytes — + immune to the >3800-byte drop. +2. **Sliding expiry.** Define an **idle timeout** (e.g. re-issue window) and an + **absolute max lifetime**. The `Authenticated` extractor / a response layer + re-issues `Set-Cookie` when the token is within the refresh threshold, so any + authenticated request renews the session. No client JWT round-trip needed — + this is the One-Tap-free win. +3. **Stateless first; revocable is a deferred fork (the true size driver).** + - **v1 — stateless sliding-window** (no server store): global invalidation is + via secret rotation; per-user logout clears the cookie client-side (exists + today). **Size S–M.** + - **v2 — revocable** (explicit "log out everywhere" / server revocation): + adds the hub's first session store (schema, restart-durability, GC sweep, + concurrency). **Size M–L. Defer** to its own sub-plan unless a concrete + requirement (e.g. security/compliance) forces it. +4. **Dedicated session secret (single secret in v1; rotation deferred).** Do + **not** reuse the actor-id `server_secret` (different blast radius). Add a + `session_secret` via the same `resolve_server_secret` pattern (env → + `hub.json` → autogen `0o600`). **v1 signs/verifies with a single secret and + ships no rotation machinery** — the hub has no rotation mechanism or schedule + today, so a (rare, manual) secret change forcing re-login is no worse than a + deploy. **`kid`/versioned two-secret overlap is deferred** (C5b) until a real + rotation requirement appears; the retrofit is cheap because a **missing `kid` + deterministically maps to the single secret**, so v1 cookies keep verifying + once `kid` is later introduced. +5. **Additive coexistence with MCP Bearer.** At the central validator, branch on + `credential.kind()`: `Cookie` → hub-session HMAC verify (+ sliding re-issue); + `Bearer` → existing JWKS decode, **unchanged** (MCP clients keep sending + `Authorization: Bearer `). Preserve the dual-credential 400 + (`server.rs:241-246`), cookie-only CSRF (`:569,820`) and WS-Origin (`:930`). +6. **Migration.** Existing cookies hold Google JWTs. On cutover either (a) accept + both during a window (cookie with `iss=accounts.google.com` → JWKS; `iss= + quarto-hub` → HMAC), then drop the Google-cookie path; or (b) force one + re-login. Prefer (a) for a seamless deploy. + +## Phases (TDD-first) + +- [ ] **C0 — Test scaffolding + token-format spec.** Failing tests for mint/verify/expiry/sliding-re-issue/tamper-rejection; a fixture hub with a known session secret. (Rotation/`kid` tests belong to the deferred C5b.) +- [ ] **C1 — Session secret (single).** `resolve_session_secret` (own field, `resolve_server_secret` pattern), a **single** secret — no `kid`/overlap. Reserve an optional `kid` slot in the token but leave it absent. Tests: secret resolves via env → `hub.json` → autogen `0o600` and survives restart. +- [ ] **C2 — Mint + verify + central routing.** HS256 mint/verify; add a `Session` path at `authenticate_claims_for_kind` (Cookie → session verify; Bearer → JWKS unchanged). Tests: valid/expired/tampered/wrong-`kid`; Bearer path untouched; dual-credential still 400. +- [ ] **C3 — Wire minting + sliding re-issue.** `auth_callback` + `auth_refresh` validate the Google token once, then mint the session cookie; `auth_me`/`auth_actor` switch to session verify; re-issue `Set-Cookie` on activity near expiry. Tests: session survives past 1 h with no One-Tap; `/auth/me` returns sliding `exp`; large Google token no longer cookie-dropped. +- [ ] **C4 — Invariants + migration + security review.** Preserve dual-credential 400, cookie-only CSRF/Origin; accept legacy Google-JWT cookies during the overlap window; review the token format against the auth-confusion protections that drove device-flow Phase 2. Tests: legacy cookie still works during window; CSRF/Origin gates intact. +- [ ] **C5 — (Deferred) revocable session store.** Only if required. Own sub-plan: store schema, durability, GC, "log out everywhere". Not in v1. +- [ ] **C5b — (Deferred) secret rotation via `kid`/overlap.** Keep current + previous secret, sign with current, verify against both during an overlap window; new cookies carry the new `kid`. Only when a concrete rotation requirement appears. Retrofits without a mass logout — missing-`kid` (v1) cookies map to the single v1 secret. Tests: rotate → old cookies still verify during overlap, new cookies use new `kid`, expired-overlap old cookies rejected. +- [ ] **C6 — Client alignment.** Rely on server sliding re-issue; retire the One-Tap renewal dependency (coordinate with Part B's B2); confirm `/auth/me` `exp` semantics. Tests: renewal works where One-Tap is blocked (FedCM/3p-cookie). +- [ ] **C7 — End-to-end verification + docs.** Real browser against a running hub: log in, idle past 1 h, keep working (no re-login, no One-Tap); confirm cookie size; confirm `q2 mcp` (Bearer) unaffected. Record invocation + observed output per the end-to-end policy. + +## Risks +- **Secret rotation forces re-login in v1** (single secret, no overlap) — acceptable because rotation is rare/manual and has no mechanism today. `kid`/overlap is deferred (C5b) and retrofits without a mass logout: missing-`kid` cookies map to the v1 secret. +- **Revocation store** is the size fork; keep v1 stateless to avoid the hub's first DB. Revisit only on a concrete requirement. +- **Security-review surface:** a new credential type must not weaken the dual-credential-confusion protections, CSRF, or WS-Origin gating. +- **WS validate-once:** expiry/revocation still only bite on reconnect (unchanged from today) — call out explicitly; live-socket revocation is out of scope. +- **Migration window:** mis-handling the legacy Google-JWT cookie during cutover would log users out; the dual-accept window (decision 6a) avoids this. + +## Braid strand structure +- **Epic `bd-ey6jg70f`** (epic, p2, open). Sub-strands **C0–C4, C6, C7** (parent-child); **C5 (revocable store)** and **C5b (`kid`/rotation)** as separate `related` strands, opened only when their requirement appears. +- Related links in place: `bd-3o8zmz46` (structural fix), `bd-qxgoti2b` (Part B; its B2 soft-depends on this). `bd-3g0aijb3` (Bearer fix) is a standalone strand, not part of this epic. + +## References +### Plans +- `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md` — umbrella index for all three plans. +- `claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md` — Part B (its B2 phase soft-depends on this plan). +- `claude-notes/plans/2026-06-10-ws-auth-expiry-handling.md` — the tactical `bd-3o8zmz46` mitigation; sliding sessions are the structural follow-up. +- `claude-notes/plans/2026-02-26-httponly-cookie-auth.md` — the current cookie model. + +### Strands +- `bd-ey6jg70f` — this epic. +- `bd-3o8zmz46` — *closed* root-cause bug (expired session → permanent offline). +- `bd-3g0aijb3` — Bearer on `/auth/actor` + `/auth/me` (standalone; not part of this epic). + +### Key files +- Secret lifecycle: `crates/quarto-hub/src/storage.rs` (115-135, 179-197, 232, 317, 339, 391), `crates/quarto-hub/src/context.rs:388`. +- Validation: `crates/quarto-hub/src/context.rs` (511-542), `crates/quarto-hub/src/server.rs` (149, 165-181, 212-247, 415, 440, 907, 938). +- Mint points + cookie: `crates/quarto-hub/src/server.rs` (291-309 `build_auth_cookie`, 701-729 callback, 844-879 refresh, 768-807 me/actor, 133 max-age, 292-298 size warning), `crates/quarto-hub/src/auth.rs` (212-217 exp, 644-664 OidcState CSRF stub, 729-737 HMAC precedent). +- WS once-at-upgrade: `crates/quarto-hub/src/server.rs:894-950`. +- Client refresh plumbing: `hub-client/src/hooks/useAuth.ts`, `hub-client/src/hooks/useAuthProbe.ts`, `hub-client/src/services/authService.ts`. From d353db580bd1e085abef6a855be09f09eccac588 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:17:43 +0100 Subject: [PATCH 02/16] sync-client: storage-only Repo for local-first (A2, bd-e2qnvb4a) Add buildRepo() that omits the WebSocket adapter when no syncServer is given; connect/createNewProject skip the peer wait in that mode. CreateProjectOptions.syncServer is now optional. Add SyncClient.flush() and flush-on-create so a local project survives an immediate reload. --- ...hub-client-connection-gated-local-first.md | 3 +- .../src/services/localFirstSync.test.ts | 60 ++++++++ .../preview-runtime/src/automergeSync.ts | 9 ++ .../src/client.local.test.ts | 74 ++++++++++ .../quarto-sync-client/src/client.test.ts | 4 + ts-packages/quarto-sync-client/src/client.ts | 138 ++++++++++++------ ts-packages/quarto-sync-client/src/types.ts | 9 +- 7 files changed, 252 insertions(+), 45 deletions(-) create mode 100644 hub-client/src/services/localFirstSync.test.ts create mode 100644 ts-packages/quarto-sync-client/src/client.local.test.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index 75657ff82..4831bae7e 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -93,7 +93,8 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. - [ ] **A0 — Test scaffolding.** Fixtures for a local-only project (no server); a fake/mock hub for the connect leg (open/create a hub project); extend `MockAuthProvider` usage; E2E harness that boots the SPA with **no** `VITE_GOOGLE_CLIENT_ID` and with an auth-required hub. Write the failing tests for A1–A4 first. - [ ] **A1 — Local-only project-set creation.** Add a local creation path to `projectSetService` (mint set-doc id client-side, store in IndexedDB, defer sync); make `waitForPeer` non-fatal for local mode. Tests: create a project fully offline; reload and see it listed; no network call made. -- [ ] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* +- [x] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* + - **Done:** `buildRepo({ syncServer? })` in `client.ts` returns a storage-only Repo (no WS adapter) when `syncServer` is empty/undefined; `connect`/`createNewProject` skip `waitForPeer` in that mode. `CreateProjectOptions.syncServer` is now optional. Added `SyncClient.flush()` (+ preview-runtime wrapper) and flush-on-create so a local project survives an immediate reload. Tests: `client.local.test.ts` (no-network contract) + `hub-client/.../localFirstSync.test.ts` (create→reload→read-back durability over fake-indexeddb). - [ ] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. - [ ] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. - [ ] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. diff --git a/hub-client/src/services/localFirstSync.test.ts b/hub-client/src/services/localFirstSync.test.ts new file mode 100644 index 000000000..2ef98cde7 --- /dev/null +++ b/hub-client/src/services/localFirstSync.test.ts @@ -0,0 +1,60 @@ +/** + * Local-first durability (A2, bd-e2qnvb4a): a project created with no sync + * server must persist to the local cache and be readable again after a + * "reload" (a fresh sync client against the same IndexedDB), all with no + * network adapter ever constructed. + * + * This test drives the real `createSyncClient` from quarto-sync-client + * (aliased to source by hub-client's vitest config) over fake-indexeddb, so + * it exercises the storage-only Repo path end to end. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; + +import { createSyncClient } from '@quarto/quarto-sync-client'; + +function freshIndexedDb() { + Object.defineProperty(globalThis, 'indexedDB', { + value: new IDBFactory(), + writable: true, + }); +} + +function client() { + return createSyncClient({ + onFileAdded: () => {}, + onFileChanged: () => {}, + onFileRemoved: () => {}, + }); +} + +describe('local-first document durability (no sync server)', () => { + beforeEach(() => { + freshIndexedDb(); + }); + + it('persists a locally-created project across a reload with no network', async () => { + // Create a project with NO syncServer → storage-only repo. + const creator = client(); + const created = await creator.createNewProject({ + files: [{ path: 'hello.qmd', content: 'local durable\n', contentType: 'text' }], + // default storage is 'indexeddb' — the fake global we installed above + }); + expect(created.indexDocId).toBeTruthy(); + expect(creator.getSyncDiagnostics().connectedPeers).toBe(0); + await creator.disconnect(); + + // "Reload": a brand-new client re-opens the same doc id, still with no + // sync server. It must read the file back out of the local cache. + const reopened = client(); + const files = await reopened.connect('', created.indexDocId); + expect(files.map((f) => f.path)).toContain('hello.qmd'); + expect(reopened.getFileContent('hello.qmd')).toBe('local durable\n'); + expect(reopened.getSyncDiagnostics().connectedPeers).toBe(0); + await reopened.disconnect(); + }); +}); diff --git a/ts-packages/preview-runtime/src/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts index 6d81e0725..5b2dd9494 100644 --- a/ts-packages/preview-runtime/src/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -300,6 +300,15 @@ export function getActorId(): string | null { return client?.getActorId() ?? null; } +/** + * Flush pending storage writes to the local cache. See + * `SyncClient.flush` — matters for local-first projects, which have no + * sync server to re-sync from. No-op when disconnected. + */ +export async function flush(): Promise { + await client?.flush(); +} + /** * Diagnostic snapshot of sync health: stranded (index-referenced but * never-loaded) files with their automerge-repo DocHandle states, plus diff --git a/ts-packages/quarto-sync-client/src/client.local.test.ts b/ts-packages/quarto-sync-client/src/client.local.test.ts new file mode 100644 index 000000000..543e8ba8e --- /dev/null +++ b/ts-packages/quarto-sync-client/src/client.local.test.ts @@ -0,0 +1,74 @@ +/** + * Local-first mode (A2, bd-e2qnvb4a): the sync client must be able to + * create and edit a project with NO sync server at all — a storage-only + * Repo with no network adapter. Authentication and a hub are required + * only when the user later connects. + * + * These tests run with `storage: 'memory'` and never start a hub, so a + * regression that reintroduces a mandatory network adapter shows up as a + * hang or a connection error rather than a pass. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { describe, it, expect } from 'vitest'; + +import { createSyncClient } from './client.js'; + +function client() { + return createSyncClient({ + onFileAdded: () => {}, + onFileChanged: () => {}, + onFileRemoved: () => {}, + }); +} + +describe('local-only project creation (no sync server)', () => { + it('createNewProject with no syncServer builds a storage-only repo', async () => { + const c = client(); + const result = await c.createNewProject({ + files: [{ path: 'notes.qmd', content: 'local first\n', contentType: 'text' }], + storage: 'memory', + }); + + expect(result.indexDocId).toBeTruthy(); + expect(result.files).toHaveLength(1); + // No server was contacted, so there is no peer. + expect(c.getSyncDiagnostics().connectedPeers).toBe(0); + // The document was authored locally and is readable with no network. + expect(c.getFileContent('notes.qmd')).toBe('local first\n'); + expect(c.isConnected()).toBe(true); + + await c.disconnect(); + }); + + it('does not hang or reject when no hub is reachable', async () => { + const c = client(); + // If this path still built a WebSocket adapter, the 1 ms peer wait + // would fire and we would fall into offline mode — here we assert the + // create simply succeeds against local storage. + await expect( + c.createNewProject({ + files: [{ path: 'a.qmd', content: 'x\n', contentType: 'text' }], + storage: 'memory', + }), + ).resolves.toMatchObject({ files: [{ path: 'a.qmd' }] }); + await c.disconnect(); + }); +}); + +describe('local-only connect (no sync server)', () => { + it('connect with an empty syncServer never contacts a network', async () => { + // A doc that does not exist locally: in local mode this must fail fast + // with the "not in local storage" verdict, NOT wait on a phantom peer. + const c = client(); + const bogus = 'automerge:2j9knpCsexT8gPeMFXQCLbTQGKC'; + await expect( + c.connect('', bogus, undefined, undefined, undefined, { + storage: 'memory', + }), + ).rejects.toThrow(); + expect(c.getSyncDiagnostics().connectedPeers).toBe(0); + await c.disconnect(); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/client.test.ts b/ts-packages/quarto-sync-client/src/client.test.ts index a593888c2..5242da151 100644 --- a/ts-packages/quarto-sync-client/src/client.test.ts +++ b/ts-packages/quarto-sync-client/src/client.test.ts @@ -121,6 +121,7 @@ function installMockRepo( find: vi.fn().mockResolvedValue(connectHandle), import: vi.fn().mockReturnValue(createHandle), create: vi.fn().mockReturnValue(createHandle), + flush: vi.fn().mockResolvedValue(undefined), networkSubsystem: mockNetworkSubsystem, }); return this as Repo; @@ -355,6 +356,7 @@ describe('createSyncClient.getBinaryDocById URL normalization (bd-4uvv)', () => }), import: vi.fn().mockReturnValue(binaryHandle), create: vi.fn().mockReturnValue(binaryHandle), + flush: vi.fn().mockResolvedValue(undefined), networkSubsystem: mockNetworkSubsystem, }); return this as Repo; @@ -391,6 +393,7 @@ describe('createSyncClient.getBinaryDocById URL normalization (bd-4uvv)', () => }), import: vi.fn().mockReturnValue(indexHandle), create: vi.fn().mockReturnValue(indexHandle), + flush: vi.fn().mockResolvedValue(undefined), networkSubsystem: { on: vi.fn(), off: vi.fn() }, }); return this as Repo; @@ -431,6 +434,7 @@ describe('createSyncClient.getBinaryDocById URL normalization (bd-4uvv)', () => }), import: vi.fn().mockReturnValue(indexHandle), create: vi.fn().mockReturnValue(indexHandle), + flush: vi.fn().mockResolvedValue(undefined), networkSubsystem: { on: vi.fn(), off: vi.fn() }, }); return this as Repo; diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 61b31d939..313c1af07 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -45,6 +45,7 @@ import type { DisconnectOptions, DisconnectReport, FindDocRetryOptions, + StorageKind, SyncClientAuthOptions, } from './types.js'; import { computeSHA256 } from './hash.js'; @@ -150,6 +151,27 @@ async function buildWsAdapter( }) as unknown as NetworkAdapter; } +/** + * Build the automerge `Repo` for a connection, with or without a network + * adapter. When `syncServer` is empty/undefined the Repo is **storage-only** + * (local-first mode, bd-e2qnvb4a): no WebSocket adapter is constructed, so + * nothing is contacted and `wsAdapter` is null. Otherwise the WS adapter is + * built as before and attached. Shared by `connect` and `createNewProject`. + */ +async function buildRepo(opts: { + syncServer?: string; + auth?: SyncClientAuthOptions; + retryIntervalMs?: number; + storage?: StorageKind; +}): Promise<{ repo: Repo; wsAdapter: NetworkAdapter | null }> { + const storage = buildStorageAdapter(opts.storage); + if (!opts.syncServer) { + return { repo: new Repo({ storage }), wsAdapter: null }; + } + const wsAdapter = await buildWsAdapter(opts.syncServer, opts.auth, opts.retryIntervalMs); + return { repo: new Repo({ network: [wsAdapter], storage }), wsAdapter }; +} + // FileDocument can be text or binary - use runtime detection type FileDocument = TextDocumentContent | BinaryDocumentContent; @@ -817,32 +839,41 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS await disconnect(); try { - state.wsAdapter = await buildWsAdapter(syncServerUrl, effectiveAuth, options.retryIntervalMs); - state.repo = new Repo({ - network: [state.wsAdapter], - storage: buildStorageAdapter(options.storage), + const built = await buildRepo({ + syncServer: syncServerUrl, + auth: effectiveAuth, + retryIntervalMs: options.retryIntervalMs, + storage: options.storage, }); + state.wsAdapter = built.wsAdapter; + state.repo = built.repo; state.actorId = actorId ?? null; state.findDocRetry = { ...DEFAULT_FIND_DOC_RETRY, ...options.findDocRetry }; trackPeers(state.repo); - // Try to connect to peer, but continue in offline mode if it fails + // Try to connect to peer, but continue in offline mode if it fails. + // Local-first mode (no sync server) has no network adapter at all, so + // there is no peer to wait for — go straight to offline-from-storage. let isOnline = false; - try { - syncLog('Waiting for peer connection...'); - await waitForPeer(state.repo, peerTimeoutMs); - syncLog('Peer connected - online mode'); - isOnline = true; - } catch (peerError) { - if (options.requireOnline) { - // Tear down the adapter's reconnect loop before failing — - // the caller asked for online-or-error, not a background - // retry it can't observe. - await disconnect(); - throw new PeerUnavailableError(syncServerUrl, peerTimeoutMs, peerError); + if (built.wsAdapter) { + try { + syncLog('Waiting for peer connection...'); + await waitForPeer(state.repo, peerTimeoutMs); + syncLog('Peer connected - online mode'); + isOnline = true; + } catch (peerError) { + if (options.requireOnline) { + // Tear down the adapter's reconnect loop before failing — + // the caller asked for online-or-error, not a background + // retry it can't observe. + await disconnect(); + throw new PeerUnavailableError(syncServerUrl, peerTimeoutMs, peerError); + } + console.warn('Peer connection failed, continuing in offline mode:', peerError); + isOnline = false; } - console.warn('Peer connection failed, continuing in offline mode:', peerError); - isOnline = false; + } else { + syncLog('Local-first mode - no sync server, storage-only repo'); } // The index staying unavailable IS fatal — nothing sensible can @@ -1488,15 +1519,14 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS await disconnect(); try { - state.wsAdapter = await buildWsAdapter( - options.syncServer, - options.auth, - options.retryIntervalMs, - ); - state.repo = new Repo({ - network: [state.wsAdapter], - storage: buildStorageAdapter(options.storage), + const built = await buildRepo({ + syncServer: options.syncServer, + auth: options.auth, + retryIntervalMs: options.retryIntervalMs, + storage: options.storage, }); + state.wsAdapter = built.wsAdapter; + state.repo = built.repo; state.findDocRetry = { ...DEFAULT_FIND_DOC_RETRY, ...options.findDocRetry }; trackPeers(state.repo); @@ -1504,23 +1534,29 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS // The timeout defaults to 1 ms so the browser falls back to IDB // quickly; callers that need guaranteed online creation (e.g. test // helpers) should pass options.peerTimeoutMs to wait for the peer. + // Local-first creation (no sync server) has no network adapter, so + // it is offline by construction — nothing to wait for. let isOnline = false; - try { - syncLog('Waiting for peer connection...'); - await waitForPeer(state.repo, options.peerTimeoutMs ?? 1); - syncLog('Peer connected - online mode'); - isOnline = true; - } catch (peerError) { - if (options.requireOnline) { - await disconnect(); - throw new PeerUnavailableError( - options.syncServer, - options.peerTimeoutMs ?? 1, - peerError, - ); + if (built.wsAdapter) { + try { + syncLog('Waiting for peer connection...'); + await waitForPeer(state.repo, options.peerTimeoutMs ?? 1); + syncLog('Peer connected - online mode'); + isOnline = true; + } catch (peerError) { + if (options.requireOnline) { + await disconnect(); + throw new PeerUnavailableError( + options.syncServer ?? '', + options.peerTimeoutMs ?? 1, + peerError, + ); + } + console.warn('Peer connection failed, creating project in offline mode:', peerError); + isOnline = false; } - console.warn('Peer connection failed, creating project in offline mode:', peerError); - isOnline = false; + } else { + syncLog('Local-first mode - creating project in storage-only repo'); } // Phase 1: Generate a document ID and resolve the actor ID before @@ -1639,6 +1675,11 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS callbacks.onConnectionChange?.(currentlyOnline); + // Persist the freshly-created index + file docs to the local cache + // before returning. Local-first projects have no server backstop, so + // this guarantees the new project survives an immediate reload. + await flush(); + return { indexDocId, files: createdFiles }; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -1690,6 +1731,18 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return state.actorId; } + /** + * Flush all pending storage writes to the local cache. + * + * Matters most for local-first projects (no sync server): there is no + * peer to re-sync from, so the local cache is the only durable copy. + * automerge-repo writes on a background debounce, so an immediate reload + * after a change could otherwise lose it. No-op when disconnected. + */ + async function flush(): Promise { + await state.repo?.flush(); + } + // Return the public API return { connect, @@ -1715,6 +1768,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS getSyncDiagnostics, createNewProject, getActorId, + flush, }; } diff --git a/ts-packages/quarto-sync-client/src/types.ts b/ts-packages/quarto-sync-client/src/types.ts index 75a04a39b..fc9d76f53 100644 --- a/ts-packages/quarto-sync-client/src/types.ts +++ b/ts-packages/quarto-sync-client/src/types.ts @@ -353,8 +353,13 @@ export interface CreateBinaryFileResult { * Options for creating a new project. */ export interface CreateProjectOptions { - /** Sync server URL */ - syncServer: string; + /** + * Sync server URL. Omit (or pass an empty string) to create a + * **local-only** project: a storage-only Repo with no network adapter, + * living entirely in the local cache. A hub connection is required only + * when the project is later opened against a server (bd-e2qnvb4a). + */ + syncServer?: string; /** List of files to create in the project */ files: Array<{ path: string; From 2eee770e895545ff591bf39f18b0ee2a496a8711 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:27:59 +0100 Subject: [PATCH 03/16] project-set: local-only creation path (A1, bd-uvtx8qux) createLocalProjectSet()/connectLocal() build a storage-only Repo (no WS adapter, no waitForPeer) via a shared buildRepo() helper; flush() and flush-on-disconnect keep local edits durable. ProjectSetEntry.syncServer is now optional (absent = local). --- ...hub-client-connection-gated-local-first.md | 3 +- hub-client/src/components/ProjectSelector.tsx | 5 +- .../services/projectSetService.local.test.ts | 57 ++++++++ hub-client/src/services/projectSetService.ts | 122 +++++++++++++++--- .../quarto-automerge-schema/src/index.ts | 19 ++- 5 files changed, 185 insertions(+), 21 deletions(-) create mode 100644 hub-client/src/services/projectSetService.local.test.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index 4831bae7e..e18fb1326 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -92,7 +92,8 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. ## Phases (TDD-first) — v1, no D1 dependency - [ ] **A0 — Test scaffolding.** Fixtures for a local-only project (no server); a fake/mock hub for the connect leg (open/create a hub project); extend `MockAuthProvider` usage; E2E harness that boots the SPA with **no** `VITE_GOOGLE_CLIENT_ID` and with an auth-required hub. Write the failing tests for A1–A4 first. -- [ ] **A1 — Local-only project-set creation.** Add a local creation path to `projectSetService` (mint set-doc id client-side, store in IndexedDB, defer sync); make `waitForPeer` non-fatal for local mode. Tests: create a project fully offline; reload and see it listed; no network call made. +- [x] **A1 — Local-only project-set creation.** Add a local creation path to `projectSetService` (mint set-doc id client-side, store in IndexedDB, defer sync); make `waitForPeer` non-fatal for local mode. Tests: create a project fully offline; reload and see it listed; no network call made. + - **Done:** `createLocalProjectSet()` + `connectLocal()` build a storage-only Repo via a shared `buildRepo(syncServer?)` helper (no WS adapter, no `waitForPeer`); `flush()` + flush-on-disconnect persist local changes. Schema: `ProjectSetEntry.syncServer` is now optional (absent = local); `addProjectToSet` omits the key rather than storing `undefined`. IDB layer uses `''` as the local sentinel (`ProjectSelector` passes `entry.syncServer ?? ''`). Test: `projectSetService.local.test.ts` (create → add → reload → still listed, `syncServer` undefined). - [x] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* - **Done:** `buildRepo({ syncServer? })` in `client.ts` returns a storage-only Repo (no WS adapter) when `syncServer` is empty/undefined; `connect`/`createNewProject` skip `waitForPeer` in that mode. `CreateProjectOptions.syncServer` is now optional. Added `SyncClient.flush()` (+ preview-runtime wrapper) and flush-on-create so a local project survives an immediate reload. Tests: `client.local.test.ts` (no-network contract) + `hub-client/.../localFirstSync.test.ts` (create→reload→read-back durability over fake-indexeddb). - [ ] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. diff --git a/hub-client/src/components/ProjectSelector.tsx b/hub-client/src/components/ProjectSelector.tsx index 1aa4e6dd1..be4ef0d23 100644 --- a/hub-client/src/components/ProjectSelector.tsx +++ b/hub-client/src/components/ProjectSelector.tsx @@ -262,8 +262,11 @@ export default function ProjectSelector({ let localProject = await projectStorage.getProjectByIndexDocId(entry.indexDocId); if (!localProject) { localProject = await projectStorage.addProject( + // A local-only project set entry has no syncServer; the empty + // string is the local sentinel in the IDB layer (falsy → the + // sync client's storage-only path). entry.indexDocId, - entry.syncServer, + entry.syncServer ?? '', entry.description, ); } diff --git a/hub-client/src/services/projectSetService.local.test.ts b/hub-client/src/services/projectSetService.local.test.ts new file mode 100644 index 000000000..894a5c57a --- /dev/null +++ b/hub-client/src/services/projectSetService.local.test.ts @@ -0,0 +1,57 @@ +/** + * Local-only project set (A1, bd-uvtx8qux): a user must be able to create + * and populate a project set with no sync server and no auth. The set doc is + * minted client-side, lives in the local cache, and survives a reload — with + * no network adapter ever constructed. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; + +import * as projectSetService from './projectSetService'; + +function freshIndexedDb() { + Object.defineProperty(globalThis, 'indexedDB', { + value: new IDBFactory(), + writable: true, + }); +} + +describe('local-only project set', () => { + beforeEach(() => { + projectSetService._resetForTesting(); + freshIndexedDb(); + }); + + afterEach(async () => { + await projectSetService.disconnect(); + }); + + it('creates a local project set offline and lists an added project after reload', async () => { + const docId = await projectSetService.createLocalProjectSet(); + expect(docId).toBeTruthy(); + + // Add a local project (no syncServer). + projectSetService.addProject({ + indexDocId: 'automerge:localProjA', + description: 'My local project', + }); + await projectSetService.flush(); + + const listed = projectSetService.listProjects(); + expect(listed.map((p) => p.description)).toContain('My local project'); + expect(listed[0]!.syncServer).toBeUndefined(); + + // "Reload": tear everything down and re-open the same set doc, still + // with no network. The project must still be there. + await projectSetService.disconnect(); + projectSetService._resetForTesting(); + + const reopened = await projectSetService.connectLocal(docId); + expect(reopened.map((p) => p.description)).toContain('My local project'); + expect(reopened[0]!.syncServer).toBeUndefined(); + }); +}); diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index 7c2a3f7af..f4638f5b1 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -84,6 +84,26 @@ function notifyProjectsChange(): void { } } +/** + * Build the module `repo` (and `wsAdapter`) for a project-set connection. + * With a `syncServerUrl` the repo carries a WebSocket adapter as before; + * without one it is **storage-only** — a local project set that lives in the + * cache with no network (bd-uvtx8qux). Shared by connect / createProjectSet / + * the local variants. + */ +function buildRepo(syncServerUrl?: string): void { + if (syncServerUrl) { + wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); + repo = new Repo({ + network: [wsAdapter], + storage: new IndexedDBStorageAdapter(), + }); + } else { + wsAdapter = null; + repo = new Repo({ storage: new IndexedDBStorageAdapter() }); + } +} + function waitForPeer(r: Repo, timeoutMs: number = 5000): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -125,14 +145,10 @@ export async function connect( ): Promise { await disconnect(); - wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); - repo = new Repo({ - network: [wsAdapter], - storage: new IndexedDBStorageAdapter(), - }); + buildRepo(syncServerUrl); const docId = projectSetDocId as DocumentId; - const foundHandle = await repo.find(docId); + const foundHandle = await repo!.find(docId); handle = foundHandle; // Check if we have a cached document locally @@ -145,7 +161,7 @@ export async function connect( cleanupFn = () => handle?.off('change', onChange); // Start background sync (but don't wait for it) - waitForPeer(repo, 5000) + waitForPeer(repo!, 5000) .then(() => onConnectionChange?.(true)) .catch(() => onConnectionChange?.(false)); @@ -155,7 +171,7 @@ export async function connect( // No local data — need to sync from server first let isOnline = false; try { - await waitForPeer(repo, 5000); + await waitForPeer(repo!, 5000); isOnline = true; } catch { isOnline = false; @@ -193,16 +209,12 @@ export async function createProjectSet( ): Promise { await disconnect(); - wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); - repo = new Repo({ - network: [wsAdapter], - storage: new IndexedDBStorageAdapter(), - }); + buildRepo(syncServerUrl); // For creation, we need the server to be reachable so the document // gets synced. Without this, the document would only exist locally. try { - await waitForPeer(repo, 10000); + await waitForPeer(repo!, 10000); } catch { await disconnect(); throw new Error( @@ -218,7 +230,7 @@ export async function createProjectSet( version: CURRENT_PROJECT_SET_SCHEMA_VERSION, } as Record; const doc = automergeFrom(initial); - handle = repo.import(automergeSerialize(doc)); + handle = repo!.import(automergeSerialize(doc)); // Subscribe to changes const onChange = () => notifyProjectsChange(); @@ -228,6 +240,77 @@ export async function createProjectSet( return handle.documentId; } +/** + * Create a new **local-only** project set, minted client-side and stored in + * the local cache with no sync server and no network adapter (bd-uvtx8qux). + * + * The document is flushed to storage before returning so it survives an + * immediate reload. `syncServer` is never set — this set is not synced to any + * hub until the project is later published (the adoption follow-on). + * + * @returns The document ID of the newly created local ProjectSetDocument. + */ +export async function createLocalProjectSet(): Promise { + await disconnect(); + + buildRepo(); // storage-only, no network + + const initial = { + projects: {}, + version: CURRENT_PROJECT_SET_SCHEMA_VERSION, + } as Record; + const doc = automergeFrom(initial); + handle = repo!.import(automergeSerialize(doc)); + + const onChange = () => notifyProjectsChange(); + handle.on('change', onChange); + cleanupFn = () => handle?.off('change', onChange); + + // Persist before returning: a local set has no server backstop. + await repo!.flush(); + + return handle.documentId; +} + +/** + * Open an existing **local-only** project set from the local cache, with no + * network adapter (bd-uvtx8qux). Used on reload for a set created by + * {@link createLocalProjectSet}. + * + * @throws If the set document is not present in the local cache. + */ +export async function connectLocal( + projectSetDocId: string, +): Promise { + await disconnect(); + + buildRepo(); // storage-only, no network + + const docId = projectSetDocId as DocumentId; + const foundHandle = await repo!.find(docId); + handle = foundHandle; + await foundHandle.whenReady(); + + const doc = handle.doc(); + if (!doc) { + throw new Error('Local project set not found in local storage.'); + } + + const onChange = () => notifyProjectsChange(); + handle.on('change', onChange); + cleanupFn = () => handle?.off('change', onChange); + + return getProjectsList(doc); +} + +/** + * Flush pending project-set writes to the local cache. Matters for local + * project sets, which have no server to re-sync from. No-op when disconnected. + */ +export async function flush(): Promise { + await repo?.flush(); +} + /** * Disconnect from the project set sync. */ @@ -236,6 +319,15 @@ export async function disconnect(): Promise { cleanupFn(); cleanupFn = null; } + // Persist any pending changes before dropping the repo. A local project + // set has no server to re-sync from, so an unflushed change would be lost. + if (repo) { + try { + await repo.flush(); + } catch { + // Best-effort — a flush failure must not block teardown. + } + } handle = null; if (wsAdapter) { wsAdapter.disconnect(); diff --git a/ts-packages/quarto-automerge-schema/src/index.ts b/ts-packages/quarto-automerge-schema/src/index.ts index b037dd6f8..b4b13f2ca 100644 --- a/ts-packages/quarto-automerge-schema/src/index.ts +++ b/ts-packages/quarto-automerge-schema/src/index.ts @@ -128,8 +128,12 @@ export function setIdentity(doc: IndexDocument, actorId: string, screenName: str export interface ProjectSetEntry { /** Automerge document ID for the project's IndexDocument (with 'automerge:' prefix). */ indexDocId: string; - /** WebSocket URL for the sync server hosting this project. */ - syncServer: string; + /** + * WebSocket URL for the sync server hosting this project. Absent for + * **local-only** projects, which live entirely in the local cache and + * are not synced to any hub (bd-uvtx8qux / bd-e2qnvb4a). + */ + syncServer?: string; /** User-provided project name/description. */ description: string; /** ISO timestamp when this project was added to the set. */ @@ -192,19 +196,26 @@ export function addProjectToSet( changed = true; } if (existing.syncServer !== entry.syncServer) { + // Assigning `undefined` deletes the key in Automerge — the right + // move for a project that transitioned to local-only. existing.syncServer = entry.syncServer; changed = true; } return changed; } - doc.projects[key] = { + // Build without a `syncServer` key when local-only. Automerge cannot + // store an explicit `undefined`, so we must omit the field entirely. + const created: ProjectSetEntry = { indexDocId: entry.indexDocId, - syncServer: entry.syncServer, description: entry.description, addedAt: timestamp, lastAccessed: timestamp, }; + if (entry.syncServer !== undefined) { + created.syncServer = entry.syncServer; + } + doc.projects[key] = created; return true; } From b865ff7d6fe923f90749ba76b3b911b41667a342 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:36:35 +0100 Subject: [PATCH 04/16] identity: persisted per-browser local actor (A3, bd-gxz6tqbk) getOrCreateLocalActor() mints a stable 32-hex Automerge actor once per browser (UserSettings.localActorId) so local edits attribute to one coherent author across reloads instead of a fresh random per-handle stub. App wiring lands in A4. --- ...hub-client-connection-gated-local-first.md | 3 +- hub-client/src/services/localActor.test.ts | 82 +++++++++++++++++++ hub-client/src/services/storage/types.ts | 7 ++ hub-client/src/services/userSettings.ts | 34 ++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 hub-client/src/services/localActor.test.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index e18fb1326..b5307a787 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -96,7 +96,8 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. - **Done:** `createLocalProjectSet()` + `connectLocal()` build a storage-only Repo via a shared `buildRepo(syncServer?)` helper (no WS adapter, no `waitForPeer`); `flush()` + flush-on-disconnect persist local changes. Schema: `ProjectSetEntry.syncServer` is now optional (absent = local); `addProjectToSet` omits the key rather than storing `undefined`. IDB layer uses `''` as the local sentinel (`ProjectSelector` passes `entry.syncServer ?? ''`). Test: `projectSetService.local.test.ts` (create → add → reload → still listed, `syncServer` undefined). - [x] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* - **Done:** `buildRepo({ syncServer? })` in `client.ts` returns a storage-only Repo (no WS adapter) when `syncServer` is empty/undefined; `connect`/`createNewProject` skip `waitForPeer` in that mode. `CreateProjectOptions.syncServer` is now optional. Added `SyncClient.flush()` (+ preview-runtime wrapper) and flush-on-create so a local project survives an immediate reload. Tests: `client.local.test.ts` (no-network contract) + `hub-client/.../localFirstSync.test.ts` (create→reload→read-back durability over fake-indexeddb). -- [ ] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. +- [x] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. + - **Done:** `getOrCreateLocalActor()` in `userSettings.ts` mints a stable 32-hex actor (16 random bytes) once per browser, persisted in the userSettings `identity` singleton (`UserSettings.localActorId`). Authoring under it is proven via the sync client (`createNewProject(files, localActor, name, color)` → `getActorId()` matches, `identities[localActor]` row present, stable across a reopen). Test: `localActor.test.ts`. *App wiring (make local-create pass this actor) lands in A4, which owns the local-first create path.* - [ ] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. - [ ] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. diff --git a/hub-client/src/services/localActor.test.ts b/hub-client/src/services/localActor.test.ts new file mode 100644 index 000000000..6ce264985 --- /dev/null +++ b/hub-client/src/services/localActor.test.ts @@ -0,0 +1,82 @@ +/** + * Persisted per-browser local actor (A3, bd-gxz6tqbk). + * + * Local edits must attribute to one coherent author across reloads instead of + * a fresh random 8-hex stub each time (today's null-actor behaviour). A stable + * 32-hex Automerge actor is minted once per browser, persisted in IndexedDB, + * and used to author every local document — with an `identities` row so the + * author displays a name, not a hex prefix. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; + +import { getOrCreateLocalActor } from './userSettings'; +import { closeDatabase } from './projectStorage'; +import { createSyncClient } from '@quarto/quarto-sync-client'; + +function freshIndexedDb() { + Object.defineProperty(globalThis, 'indexedDB', { + value: new IDBFactory(), + writable: true, + }); +} + +function client() { + return createSyncClient({ + onFileAdded: () => {}, + onFileChanged: () => {}, + onFileRemoved: () => {}, + }); +} + +describe('persisted local actor', () => { + beforeEach(() => { + closeDatabase(); + freshIndexedDb(); + }); + + it('mints a valid 32-hex actor that is stable across calls and reloads', async () => { + const first = await getOrCreateLocalActor(); + // 16 random bytes → 32 lowercase hex chars: a valid Automerge actor id. + expect(first).toMatch(/^[0-9a-f]{32}$/); + + // Same value within a session. + expect(await getOrCreateLocalActor()).toBe(first); + + // "Reload": close and re-open the DB — must read back the same actor, + // not re-randomize. + closeDatabase(); + expect(await getOrCreateLocalActor()).toBe(first); + }); + + it('authors local documents under the local actor with an identities row', async () => { + const localActor = await getOrCreateLocalActor(); + + // Create a local project authored under the local actor. + const creator = client(); + const created = await creator.createNewProject( + { files: [{ path: 'me.qmd', content: 'mine\n', contentType: 'text' }] }, + localActor, + 'You', + '#E91E63', + ); + expect(creator.getActorId()).toBe(localActor); + // The index identities row maps the local actor → display name. + const idx = creator.getIndexHandle()?.doc(); + expect(idx?.identities?.[localActor]).toEqual({ name: 'You', color: '#E91E63' }); + await creator.disconnect(); + + // "Reload": re-open the same project under the same local actor. The + // author does not re-randomize. + const reopened = client(); + await reopened.connect('', created.indexDocId, localActor, 'You', '#E91E63'); + expect(reopened.getActorId()).toBe(localActor); + const idx2 = reopened.getIndexHandle()?.doc(); + expect(idx2?.identities?.[localActor]).toEqual({ name: 'You', color: '#E91E63' }); + await reopened.disconnect(); + }); +}); diff --git a/hub-client/src/services/storage/types.ts b/hub-client/src/services/storage/types.ts index 2cfad4a86..1bf94c978 100644 --- a/hub-client/src/services/storage/types.ts +++ b/hub-client/src/services/storage/types.ts @@ -71,6 +71,13 @@ export interface UserSettings { userColor: string; createdAt: string; updatedAt: string; + /** + * Stable per-browser Automerge actor id (32 hex chars) used to author + * local-only documents. Minted once by {@link getOrCreateLocalActor} so + * local edits attribute coherently across reloads (bd-gxz6tqbk). Absent + * until the first local document is authored. + */ + localActorId?: string; } /** diff --git a/hub-client/src/services/userSettings.ts b/hub-client/src/services/userSettings.ts index 917a7de92..48b416a91 100644 --- a/hub-client/src/services/userSettings.ts +++ b/hub-client/src/services/userSettings.ts @@ -128,3 +128,37 @@ export async function getUserId(): Promise { const settings = await getUserIdentity(); return settings.userId; } + +/** + * Generate a valid Automerge actor id: 16 random bytes → 32 lowercase hex. + */ +function generateActorId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Get the stable per-browser local actor id, minting and persisting one on + * first use (bd-gxz6tqbk). + * + * Local-only documents are authored under this actor so edits attribute to a + * single coherent author across reloads — replacing the null-actor behaviour + * where Automerge assigns a fresh random actor per handle on every load. + * Persisted alongside the user identity in the userSettings store. + */ +export async function getOrCreateLocalActor(): Promise { + const settings = await getUserIdentity(); + if (settings.localActorId) { + return settings.localActorId; + } + + const localActorId = generateActorId(); + const db = await getDb(); + await db.put(STORES.USER_SETTINGS, { + ...settings, + localActorId, + updatedAt: new Date().toISOString(), + }); + return localActorId; +} From 1a7af694e9112ff5fb5331c31e30a63dc87d4d9b Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:58:45 +0100 Subject: [PATCH 05/16] hub-client: connection-gated auth + local-first default (A4, bd-u4p8xhdc) Remove the LoginScreen entry gate; sign-in is now an opt-in overlay via a header 'Connect to a hub' control. useProjectSet auto-creates a local project set on first run; create/open branch local vs hub by syncServer (local authored under the per-browser local actor). useAuthProbe and the auth-loss teardown are scoped to hub projects so a local project is never torn down. --- ...hub-client-connection-gated-local-first.md | 6 +- hub-client/src/App.tsx | 102 +++++++++++------- .../ProjectSelector.connect.test.tsx | 92 ++++++++++++++++ .../ProjectSelector.import.test.tsx | 7 +- hub-client/src/components/ProjectSelector.tsx | 87 +++++++-------- .../src/components/auth/LoginScreen.tsx | 22 +++- .../src/hooks/useProjectSet.local.test.ts | 102 ++++++++++++++++++ hub-client/src/hooks/useProjectSet.ts | 24 +++-- 8 files changed, 350 insertions(+), 92 deletions(-) create mode 100644 hub-client/src/components/ProjectSelector.connect.test.tsx create mode 100644 hub-client/src/hooks/useProjectSet.local.test.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index b5307a787..9304a94c5 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -91,14 +91,16 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. ## Phases (TDD-first) — v1, no D1 dependency -- [ ] **A0 — Test scaffolding.** Fixtures for a local-only project (no server); a fake/mock hub for the connect leg (open/create a hub project); extend `MockAuthProvider` usage; E2E harness that boots the SPA with **no** `VITE_GOOGLE_CLIENT_ID` and with an auth-required hub. Write the failing tests for A1–A4 first. +- [x] **A0 — Test scaffolding.** Fixtures for a local-only project (no server); a fake/mock hub for the connect leg (open/create a hub project); extend `MockAuthProvider` usage; E2E harness that boots the SPA with **no** `VITE_GOOGLE_CLIENT_ID` and with an auth-required hub. Write the failing tests for A1–A4 first. + - **Done:** the reusable scaffolding is the set of red-then-green `*.local.test.*` fixtures established per phase over fake-indexeddb + a no-server sync-client factory: `client.local.test.ts`, `localFirstSync.test.ts` (A2), `projectSetService.local.test.ts` (A1), `localActor.test.ts` (A3), `useProjectSet.local.test.ts` + `ProjectSelector.connect.test.tsx` (A4). Each was written failing before its phase's implementation. The auth-required-hub E2E harness proper is A7v1. - [x] **A1 — Local-only project-set creation.** Add a local creation path to `projectSetService` (mint set-doc id client-side, store in IndexedDB, defer sync); make `waitForPeer` non-fatal for local mode. Tests: create a project fully offline; reload and see it listed; no network call made. - **Done:** `createLocalProjectSet()` + `connectLocal()` build a storage-only Repo via a shared `buildRepo(syncServer?)` helper (no WS adapter, no `waitForPeer`); `flush()` + flush-on-disconnect persist local changes. Schema: `ProjectSetEntry.syncServer` is now optional (absent = local); `addProjectToSet` omits the key rather than storing `undefined`. IDB layer uses `''` as the local sentinel (`ProjectSelector` passes `entry.syncServer ?? ''`). Test: `projectSetService.local.test.ts` (create → add → reload → still listed, `syncServer` undefined). - [x] **A2 — Storage-only Repo + optional `syncServer`.** Add a `network: []` / deferred-adapter construction path in `SyncClient` (`connect`, `createNewProject`); make `syncServer` optional through the creation flow + schema. Tests: create/edit/persist a doc with no network adapter; VFS + WASM render works offline (render path is already network-free). *(A1 and A2 apply the same "network-less Repo + non-fatal `waitForPeer`" change at two construction sites — `projectSetService.ts:129/197` and `client.ts:821/1496`; factor one shared helper + fixture rather than two parallel edits.)* - **Done:** `buildRepo({ syncServer? })` in `client.ts` returns a storage-only Repo (no WS adapter) when `syncServer` is empty/undefined; `connect`/`createNewProject` skip `waitForPeer` in that mode. `CreateProjectOptions.syncServer` is now optional. Added `SyncClient.flush()` (+ preview-runtime wrapper) and flush-on-create so a local project survives an immediate reload. Tests: `client.local.test.ts` (no-network contract) + `hub-client/.../localFirstSync.test.ts` (create→reload→read-back durability over fake-indexeddb). - [x] **A3 — Persisted local actor.** Generate + persist a per-browser local actor in IndexedDB; author local docs under it; write the local `identities` row. In v1 this actor **persists for the life of the local project** — no switch (that is the adoption follow-on). Tests: two reloads → same actor on all changes (no re-randomization); authorship displays the local name, not an 8-hex stub. - **Done:** `getOrCreateLocalActor()` in `userSettings.ts` mints a stable 32-hex actor (16 random bytes) once per browser, persisted in the userSettings `identity` singleton (`UserSettings.localActorId`). Authoring under it is proven via the sync client (`createNewProject(files, localActor, name, color)` → `getActorId()` matches, `identities[localActor]` row present, stable across a reopen). Test: `localActor.test.ts`. *App wiring (make local-create pass this actor) lands in A4, which owns the local-first create path.* -- [ ] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. +- [x] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. + - **Done:** Removed the `AUTH_ENABLED`-gated LoginScreen + loading gates; sign-in is now an opt-in overlay shown only via `showLogin` (header "Connect to a hub") or a redirect `authError`. `useProjectSet` auto-creates a **local** project set on first run (no pointer + no legacy) and branches `connect`/`connectLocal` by pointer syncServer. `handleProjectCreated` branches local (authored under the local actor, no server) vs hub; `resolveActorForOpen` picks local-actor vs HMAC by the project's syncServer. `useAuthProbe` + the disconnect-on-auth-loss effect are scoped to `project?.syncServer` (hub only) so a local project is never torn down. Header shows "Connect to a hub" (disconnected) / "Signed in as X · Sign out" (connected); Create/Import default to local (sync-server field removed). Tests: `useProjectSet.local.test.ts`, `ProjectSelector.connect.test.tsx`. Full hub-client unit suite (710) + production build green. *(E2E boot + auth-required-hub verification is A7v1.)* - [ ] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. > The adoption follow-on (A5 — connect+adopt, A6 — offline-doc durability/D1, A7adopt — E2E) lives in `2026-07-06-hub-client-local-project-adoption.md`, `conditional-blocks` on `bd-10bdjmjb`. diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 08db74471..d5247478d 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -32,7 +32,7 @@ import { import type { ProjectFile } from '@quarto/preview-runtime'; import * as projectStorage from './services/projectStorage'; import { installDebugApi } from './services/debugApi'; -import { getUserIdentity, updateUserName } from './services/userSettings'; +import { getUserIdentity, updateUserName, getOrCreateLocalActor } from './services/userSettings'; import { useRouting } from './hooks/useRouting'; import { useProjectSet } from './hooks/useProjectSet'; import { useAuth } from './hooks/useAuth'; @@ -108,6 +108,10 @@ function App() { // preview so recorded engine output can be spliced into the rendered AST. const [captures, setCaptures] = useState>({}); const [isOnline, setIsOnline] = useState(false); + // Connection-gated auth (bd-u4p8xhdc): the app opens with no login gate. + // Signing in is triggered only when the user chooses to connect to a hub; + // `showLogin` renders the sign-in screen as an opt-in overlay. + const [showLogin, setShowLogin] = useState(false); // bd-sfet3264 (Phase 2D + Phase 4b): track which q2 executors are online for // the connected project (via the index-handle capability beacon) and expose @@ -123,8 +127,10 @@ function App() { // definitive 401/403 evidence ever clears auth — never network errors. // Past the token's exp, useAuth's expiry timer logs out on the first 401 // (preempting this probe's two-strike); the probe governs earlier drops. + // Only probe for a hub-backed project (one with a sync server). A local + // project has no server, so its perpetual `!isOnline` is not an auth signal. useAuthProbe({ - enabled: AUTH_ENABLED && !!auth && !!project && !isOnline, + enabled: AUTH_ENABLED && !!auth && !!project?.syncServer && !isOnline, triggerRefresh, onAuthRejected: expireSession, }); @@ -145,6 +151,16 @@ function App() { [triggerRefresh], ); + // Choose the authoring actor for a project when opening it. A local + // project (no sync server) is authored under the stable per-browser local + // actor — no server round-trip. A hub project uses the server-derived HMAC + // actor via `resolveActorId` (bd-gxz6tqbk / bd-u4p8xhdc). + const resolveActorForOpen = useCallback( + (indexDocId: string, syncServer: string): Promise => + syncServer ? resolveActorId(indexDocId) : getOrCreateLocalActor(), + [resolveActorId], + ); + // Capture auth error from redirect query param (once, before URL is cleaned). const [authError] = useState(() => { const has = new URLSearchParams(window.location.search).has('auth_error'); @@ -256,7 +272,7 @@ function App() { setIsConnecting(true); setConnectionError(null); try { - const newActorId = await resolveActorId(targetProject.indexDocId); + const newActorId = await resolveActorForOpen(targetProject.indexDocId, targetProject.syncServer); if (newActorId === null) return; const { files: loadedFiles, contents } = await connectAndLoadContents(targetProject.syncServer, targetProject.indexDocId, newActorId, screenName, cursorColor); setProject(targetProject); @@ -384,7 +400,7 @@ function App() { setIsConnecting(true); setConnectionError(null); try { - const newActorId = await resolveActorId(targetProject.indexDocId); + const newActorId = await resolveActorForOpen(targetProject.indexDocId, targetProject.syncServer); if (newActorId === null) return; const { files: loadedFiles, contents } = await connectAndLoadContents(targetProject.syncServer, targetProject.indexDocId, newActorId, screenName, cursorColor); setProject(targetProject); @@ -409,11 +425,12 @@ function App() { loadFromUrl(); }, [route, navigateToProjectSelector, navigateToProject, navigateToFile]); - // Disconnect sync when auth is lost (token expired or user logged out). - // Without this, the WebSocket adapter keeps retrying with an expired cookie - // and the user sees "Connection lost" instead of the login screen. + // Disconnect sync when auth is lost (token expired or user logged out) — + // but ONLY for a hub-backed project (one with a sync server). A local + // project has no server and no auth dependency, so an auth-loss event must + // never tear it down (bd-u4p8xhdc). useEffect(() => { - if (AUTH_ENABLED && !auth && !authLoading && project) { + if (AUTH_ENABLED && !auth && !authLoading && project?.syncServer) { disconnect(); setProject(null); setFiles([]); @@ -488,7 +505,7 @@ function App() { setConnectionError(null); try { - const newActorId = await resolveActorId(selectedProject.indexDocId); + const newActorId = await resolveActorForOpen(selectedProject.indexDocId, selectedProject.syncServer); if (newActorId === null) return; const { files: loadedFiles, contents } = await connectAndLoadContents(selectedProject.syncServer, selectedProject.indexDocId, newActorId, screenName, cursorColor); setProject(selectedProject); @@ -506,7 +523,7 @@ function App() { } finally { setIsConnecting(false); } - }, [navigateToProject, navigateToFile, resolveActorId, screenName, cursorColor]); + }, [navigateToProject, navigateToFile, resolveActorForOpen, screenName, cursorColor]); const handleDisconnect = useCallback(async () => { await disconnect(); @@ -540,31 +557,43 @@ function App() { mimeType: f.mime_type, })); - // Create the Automerge documents. The resolveActorId callback is - // called after the index doc is created (to derive the HMAC actor - // ID from the indexDocId) but before any file docs are written. - // - // Resolve only the runtime connection value (the WS adapter needs an - // absolute ws(s):// URL); the portable `syncServer` is what we store - // and share below, so it stays origin-independent under a subpath. - const result = await createNewProject({ - syncServer: resolveSyncServerUrl(syncServer), - files, - }, undefined, screenName, cursorColor, resolveActorId); - - // Store the project in IndexedDB + // Create the Automerge documents. An empty syncServer means a + // local-first project (bd-u4p8xhdc): a storage-only Repo authored + // under the stable per-browser local actor, with no server round-trip. + // A hub project uses today's path — resolve the HMAC actor after the + // index doc exists, and connect the WS adapter (resolveSyncServerUrl + // gives the absolute ws(s):// URL; the portable `syncServer` is what we + // store and share, so it stays origin-independent under a subpath). + const isLocal = !syncServer; + const result = isLocal + ? await createNewProject( + { files }, + await getOrCreateLocalActor(), + screenName, + cursorColor, + ) + : await createNewProject( + { syncServer: resolveSyncServerUrl(syncServer), files }, + undefined, + screenName, + cursorColor, + resolveActorId, + ); + + // Store the project in IndexedDB (empty syncServer for a local project). const projectEntry = await projectStorage.addProject( result.indexDocId, syncServer, title ); - // Also add to the synced project set + // Also add to the connected project set. Omit syncServer for a local + // project so the set entry stays local too. if (projectSetStateRef.current.status === 'connected') { try { projectSetActions.addProject({ indexDocId: result.indexDocId, - syncServer, + syncServer: syncServer || undefined, description: title, }); } catch { @@ -594,21 +623,18 @@ function App() { } }, [navigateToProject, resolveActorId, screenName, cursorColor]); - // Auth gate: when auth is enabled, require login before showing the app. - // Show a loading spinner while checking auth status to avoid login flash. - if (AUTH_ENABLED && authLoading) { - return ( -
-
Loading...
-
- ); - } - - if (AUTH_ENABLED && !auth) { + // Connection-gated auth (bd-u4p8xhdc): there is NO login gate. The app + // opens straight into a usable, local-first selector. Sign-in is shown only + // when the user opts to connect to a hub (or a redirect returned an error). + // `useAuth` probes /auth/me on mount to recognize an existing session; a + // 401/network failure is non-blocking, so a backend-less static deploy + // still renders. + if ((showLogin || authError) && !auth) { return ( setShowLogin(false)} /> ); } @@ -672,7 +698,9 @@ function App() { onProjectCreated={handleProjectCreated} isConnecting={isConnecting} error={connectionError} - onSignOut={AUTH_ENABLED ? logout : undefined} + isHubConnected={!!auth} + onConnectToHub={() => setShowLogin(true)} + onSignOut={auth ? logout : undefined} authEmail={auth?.email} authPicture={auth?.picture} onScreenNameChange={setScreenName} diff --git a/hub-client/src/components/ProjectSelector.connect.test.tsx b/hub-client/src/components/ProjectSelector.connect.test.tsx new file mode 100644 index 000000000..3e04a8aa6 --- /dev/null +++ b/hub-client/src/components/ProjectSelector.connect.test.tsx @@ -0,0 +1,92 @@ +/** + * Header hub-connection control (A4, bd-u4p8xhdc). + * + * The account-level control shows "Connect to a hub" when disconnected + * (triggering sign-in) and the signed-in identity + Sign out when connected. + * It is deliberately separate from the per-project "Connect to Project" + * (join-by-doc-id) action. The create form no longer asks for a sync server — + * Create defaults to local. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +vi.mock('@quarto/preview-runtime', () => ({ + getProjectChoices: vi.fn().mockResolvedValue([]), + createProject: vi.fn(), + importProjectFromZip: vi.fn(), +})); + +vi.mock('../services/projectStorage', () => ({ + listProjects: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../services/userSettings', () => ({ + getUserIdentity: vi.fn().mockResolvedValue(null), + updateUserName: vi.fn(), + updateUserColor: vi.fn(), + resetUserIdentity: vi.fn(), +})); + +vi.mock('./ThemeContext', () => ({ + useTheme: () => ({ colorScheme: 'auto', cycleColorScheme: vi.fn() }), +})); + +import ProjectSelector from './ProjectSelector'; + +const baseProps = { + onSelectProject: vi.fn(), + onProjectCreated: vi.fn(), + projectSetStatus: 'connected' as const, + projectSetEntries: [], +}; + +describe('ProjectSelector hub-connection header control', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + cleanup(); + }); + + it('shows "Connect to a hub" when disconnected and calls onConnectToHub', () => { + const onConnectToHub = vi.fn(); + render( + , + ); + const btn = screen.getByRole('button', { name: /connect to a hub/i }); + fireEvent.click(btn); + expect(onConnectToHub).toHaveBeenCalledTimes(1); + // No Sign out affordance while disconnected. + expect(screen.queryByText(/sign out/i)).toBeNull(); + }); + + it('shows the signed-in identity + Sign out when connected', () => { + const onSignOut = vi.fn(); + render( + , + ); + const btn = screen.getByRole('button', { name: /signed in as user@example\.com · sign out/i }); + fireEvent.click(btn); + expect(onSignOut).toHaveBeenCalledTimes(1); + // The disconnected affordance is gone. + expect(screen.queryByRole('button', { name: /^connect to a hub$/i })).toBeNull(); + }); + + it('the create form no longer asks for a sync server (local default)', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /create new project/i })); + expect(screen.queryByLabelText(/sync server url/i)).toBeNull(); + }); +}); diff --git a/hub-client/src/components/ProjectSelector.import.test.tsx b/hub-client/src/components/ProjectSelector.import.test.tsx index 653b138e8..7eb4f30db 100644 --- a/hub-client/src/components/ProjectSelector.import.test.tsx +++ b/hub-client/src/components/ProjectSelector.import.test.tsx @@ -27,7 +27,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import { DEFAULT_SYNC_SERVER } from '../utils/routing'; const { importMock } = vi.hoisted(() => ({ importMock: vi.fn() })); @@ -133,12 +132,14 @@ describe('ProjectSelector — Import from ZIP', () => { expect(passedBytes).toBeInstanceOf(Uint8Array); expect(Array.from(passedBytes)).toEqual(Array.from(ZIP_BYTES)); - // The parsed files + form values flow to the create callback. + // The parsed files + form values flow to the create callback. Import now + // defaults to local (empty sync server) when no hub project set is + // connected — the local-first default (bd-u4p8xhdc). const [files, title, projectType, syncServer] = onProjectCreated.mock.calls[0]; expect(files).toEqual(parsed); expect(title).toBe('My Project'); expect(projectType).toBe('imported'); - expect(syncServer).toBe(DEFAULT_SYNC_SERVER); + expect(syncServer).toBe(''); }); it('surfaces a parse error and does not create a project', async () => { diff --git a/hub-client/src/components/ProjectSelector.tsx b/hub-client/src/components/ProjectSelector.tsx index be4ef0d23..23e1346ee 100644 --- a/hub-client/src/components/ProjectSelector.tsx +++ b/hub-client/src/components/ProjectSelector.tsx @@ -24,7 +24,11 @@ interface Props { error?: string | null; /** Called when a new project is created with scaffold files */ onProjectCreated?: (files: ProjectFile[], title: string, projectType: string, syncServer: string) => void; - /** Called when user signs out of Google. Only passed when auth is enabled. */ + /** Whether the app is connected to a hub (has a signed-in session). */ + isHubConnected?: boolean; + /** Called when the user chooses to connect to a hub (triggers sign-in). */ + onConnectToHub?: () => void; + /** Called when user signs out. Passed only when signed in to a hub. */ onSignOut?: () => void; /** Authenticated user's email (for display). */ authEmail?: string; @@ -64,6 +68,8 @@ export default function ProjectSelector({ isConnecting, error: connectionError, onProjectCreated, + isHubConnected, + onConnectToHub, onSignOut, authEmail, authPicture, @@ -361,10 +367,9 @@ export default function ProjectSelector({ return; } - if (!syncServer.trim()) { - setFormError('Sync Server URL is required'); - return; - } + // Create targets the active project set: a hub server when connected to + // one, otherwise local-only (empty string) — the local-first default. + const createTargetServer = projectSetSyncServer ?? ''; setIsCreating(true); @@ -388,7 +393,7 @@ export default function ProjectSelector({ // Call the callback with the scaffold files // The parent component (or k-tsqm task) will handle Automerge document creation if (onProjectCreated) { - onProjectCreated(result.files, createProjectTitle.trim(), createProjectType, syncServer.trim()); + onProjectCreated(result.files, createProjectTitle.trim(), createProjectType, createTargetServer); } else { // If no callback, show success message with file list const fileList = result.files.map(f => f.path).join(', '); @@ -440,10 +445,9 @@ export default function ProjectSelector({ return; } - if (!syncServer.trim()) { - setFormError('Sync Server URL is required'); - return; - } + // Import targets the active project set (hub server when connected, + // otherwise local-only — the local-first default). + const importTargetServer = projectSetSyncServer ?? ''; setIsImporting(true); @@ -457,7 +461,7 @@ export default function ProjectSelector({ } if (onProjectCreated) { - onProjectCreated(files, importTitle.trim(), 'imported', syncServer.trim()); + onProjectCreated(files, importTitle.trim(), 'imported', importTargetServer); // Reset the form; the parent handles navigation into the project. setShowImportForm(false); setImportFile(null); @@ -554,17 +558,36 @@ export default function ProjectSelector({

Multiplayer editing for your Quarto projects

- {onSignOut && ( - + {/* + Account-level hub connection control (bd-u4p8xhdc). When not + connected, offer "Connect to a hub" (triggers sign-in). When + connected, show the signed-in identity + Sign out. This is + session-scoped state, kept out of the per-project action row so + it does not collide with "Connect to Project" (join-by-doc-id). + */} + {isHubConnected ? ( + onSignOut && ( + + ) + ) : ( + onConnectToHub && ( + + ) )}
-
- - setSyncServer(e.target.value)} - placeholder="wss://sync.automerge.org" - /> -
-
- - setSyncServer(e.target.value)} - placeholder="wss://sync.automerge.org" - /> -
+ )}
); diff --git a/hub-client/src/hooks/useProjectSet.local.test.ts b/hub-client/src/hooks/useProjectSet.local.test.ts new file mode 100644 index 000000000..754664b03 --- /dev/null +++ b/hub-client/src/hooks/useProjectSet.local.test.ts @@ -0,0 +1,102 @@ +/** + * Local-first project-set bootstrap (A4, bd-u4p8xhdc). + * + * With no auth and no server, the app must open straight into a usable + * selector: when there is no project-set pointer and no legacy projects, + * useProjectSet auto-creates a *local* project set (no sync server) and + * reaches the `connected` state — never `needs-setup`, which would have + * forced a server-backed setup screen behind a login gate. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, cleanup } from '@testing-library/react'; + +const { pointerStore } = vi.hoisted(() => ({ + pointerStore: { current: null as { projectSetDocId: string; syncServer: string } | null }, +})); + +vi.mock('../services/projectSetStorage', () => ({ + getProjectSetPointer: vi.fn(async () => pointerStore.current), + setProjectSetPointer: vi.fn(async (projectSetDocId: string, syncServer: string) => { + pointerStore.current = { projectSetDocId, syncServer }; + }), +})); + +vi.mock('../services/projectStorage', () => ({ + listProjects: vi.fn(async () => []), +})); + +vi.mock('../services/projectSetReconciler', () => ({ + reconcileIntoConnectedProjectSet: vi.fn(async () => 0), +})); + +const { + createLocalProjectSetMock, + connectMock, + connectLocalMock, + listProjectsMock, +} = vi.hoisted(() => ({ + createLocalProjectSetMock: vi.fn(async () => 'automerge:localSet'), + connectMock: vi.fn(async () => []), + connectLocalMock: vi.fn(async () => []), + listProjectsMock: vi.fn(() => []), +})); + +vi.mock('../services/projectSetService', () => ({ + setProjectSetHandlers: vi.fn(), + connect: connectMock, + connectLocal: connectLocalMock, + createLocalProjectSet: createLocalProjectSetMock, + createProjectSet: vi.fn(), + listProjects: listProjectsMock, + addProject: vi.fn(), + removeProject: vi.fn(), + updateProjectDescription: vi.fn(), + touchProject: vi.fn(), + addProjectsBulk: vi.fn(), + getProjectSetDocId: vi.fn(() => 'automerge:localSet'), + disconnect: vi.fn(async () => {}), +})); + +import { useProjectSet } from './useProjectSet'; +import * as projectSetStorage from '../services/projectSetStorage'; + +describe('useProjectSet local-first bootstrap', () => { + beforeEach(() => { + pointerStore.current = null; + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('auto-creates a local project set when there is no pointer and no legacy', async () => { + const { result } = renderHook(() => useProjectSet()); + + await waitFor(() => expect(result.current[0].status).toBe('connected')); + + expect(createLocalProjectSetMock).toHaveBeenCalledTimes(1); + // The local pointer is persisted with an empty (local) sync server. + expect(projectSetStorage.setProjectSetPointer).toHaveBeenCalledWith('automerge:localSet', ''); + // No networked connect was attempted. + expect(connectMock).not.toHaveBeenCalled(); + // The set reports as local (no sync server). + expect(result.current[1].getSyncServer()).toBe(''); + }); + + it('opens an existing local pointer via connectLocal, not the networked connect', async () => { + pointerStore.current = { projectSetDocId: 'automerge:existingLocal', syncServer: '' }; + + const { result } = renderHook(() => useProjectSet()); + await waitFor(() => expect(result.current[0].status).toBe('connected')); + + expect(connectLocalMock).toHaveBeenCalledWith('automerge:existingLocal'); + expect(connectMock).not.toHaveBeenCalled(); + expect(createLocalProjectSetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/hub-client/src/hooks/useProjectSet.ts b/hub-client/src/hooks/useProjectSet.ts index d24398028..14a54e382 100644 --- a/hub-client/src/hooks/useProjectSet.ts +++ b/hub-client/src/hooks/useProjectSet.ts @@ -114,13 +114,16 @@ export function useProjectSet(): [ProjectSetState, ProjectSetActions] { const pointer = await getProjectSetPointer(); if (pointer) { - // Have a pointer — connect to the project set + // Have a pointer — connect to the project set. An empty syncServer + // marks a local-only set: open it from the cache with no network. setStatus('connecting'); syncServerRef.current = pointer.syncServer; - const entries = await projectSetService.connect( - pointer.syncServer, - pointer.projectSetDocId, - ); + const entries = pointer.syncServer + ? await projectSetService.connect( + pointer.syncServer, + pointer.projectSetDocId, + ) + : await projectSetService.connectLocal(pointer.projectSetDocId); setProjects(entries); setStatus('connected'); } else { @@ -130,7 +133,16 @@ export function useProjectSet(): [ProjectSetState, ProjectSetActions] { setLegacyProjects(legacy); setStatus('needs-migration'); } else { - setStatus('needs-setup'); + // Local-first (bd-u4p8xhdc): no pointer and nothing to migrate → + // auto-create a local project set so the app opens straight into + // a usable selector with no login and no server. The set is + // minted client-side and lives in the local cache; its pointer + // records an empty syncServer to mark it local. + const docId = await projectSetService.createLocalProjectSet(); + await setProjectSetPointer(docId, ''); + syncServerRef.current = ''; + setProjects([]); + setStatus('connected'); } } } catch (err) { From 665b4119aa93200a11992ba5e8e2febb6701a1e0 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:07:44 +0100 Subject: [PATCH 06/16] hub-client: local-first E2E + user docs (A7v1, bd-a1gpy16v) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add e2e/local-first.spec.ts (real-browser: no login gate; create local project offline → navigate into editor → reload persists). Add LOCAL-FIRST.md user docs, cross-linked from README. The OIDC sign-in leg is not automated (no test IdP); manual path documented. --- ...hub-client-connection-gated-local-first.md | 7 ++- hub-client/LOCAL-FIRST.md | 59 ++++++++++++++++++ hub-client/README.md | 5 ++ hub-client/e2e/local-first.spec.ts | 60 +++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 hub-client/LOCAL-FIRST.md create mode 100644 hub-client/e2e/local-first.spec.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index 9304a94c5..aba13b1d1 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -101,7 +101,12 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. - **Done:** `getOrCreateLocalActor()` in `userSettings.ts` mints a stable 32-hex actor (16 random bytes) once per browser, persisted in the userSettings `identity` singleton (`UserSettings.localActorId`). Authoring under it is proven via the sync client (`createNewProject(files, localActor, name, color)` → `getActorId()` matches, `identities[localActor]` row present, stable across a reopen). Test: `localActor.test.ts`. *App wiring (make local-create pass this actor) lands in A4, which owns the local-first create path.* - [x] **A4 — Gate removal.** Delete the App.tsx render gate; reconcile `AUTH_ENABLED` UI props + the `useAuthProbe` guard to runtime state; scope the auth-loss→disconnect effect (`App.tsx:400-409`) to connected projects. (`useAuth` is already non-blocking on `/auth/me` failure — no hook change needed there.) Tests: SPA usable with no IdP configured and with a backend-unreachable deploy; login no longer precedes first render; local project survives an auth-loss event. - **Done:** Removed the `AUTH_ENABLED`-gated LoginScreen + loading gates; sign-in is now an opt-in overlay shown only via `showLogin` (header "Connect to a hub") or a redirect `authError`. `useProjectSet` auto-creates a **local** project set on first run (no pointer + no legacy) and branches `connect`/`connectLocal` by pointer syncServer. `handleProjectCreated` branches local (authored under the local actor, no server) vs hub; `resolveActorForOpen` picks local-actor vs HMAC by the project's syncServer. `useAuthProbe` + the disconnect-on-auth-loss effect are scoped to `project?.syncServer` (hub only) so a local project is never torn down. Header shows "Connect to a hub" (disconnected) / "Signed in as X · Sign out" (connected); Create/Import default to local (sync-server field removed). Tests: `useProjectSet.local.test.ts`, `ProjectSelector.connect.test.tsx`. Full hub-client unit suite (710) + production build green. *(E2E boot + auth-required-hub verification is A7v1.)* -- [ ] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. +- [x] **A7v1 — End-to-end verification + docs.** Real browser: create a project offline, edit, reload (persists), then connect to a running hub and open/create a **hub** project. Record the exact steps + observed output per the repo's end-to-end policy. User-facing docs for local-first + connecting. + - **Done (local leg, automated real-browser E2E):** `e2e/local-first.spec.ts` drives headless Chromium against the production bundle: + - Invocation: `cd hub-client && VITE_E2E=1 npm run build && npx playwright test local-first.spec.ts --project=chromium`. + - Observed: **2 passed.** (1) The app opens directly to the "Your Projects" selector with **no login gate**; the header shows a **"Connect to a hub"** button. (2) Creating a project (first project type, local — the create form has no sync-server field) navigates into the editor at `#/p//file/_quarto.yml` with **no hub contacted**, and after `page.goto('/')` the project is **still listed** (persisted in IndexedDB). This exercises gate removal + local bootstrap + local-actor authoring + durability through the real binary/bundle. + - **Docs:** `hub-client/LOCAL-FIRST.md` (user-facing local-first + connect model), cross-linked from `README.md`; distinguished from the PWA asset cache in `OFFLINE.md`. + - **Hub-connect leg (open/create a hub project after sign-in):** requires a live OIDC provider, which the headless harness has no client id for, so it is **not automated**. Manual verification path recorded in `LOCAL-FIRST.md` + the local-prod runbook in the repo `CLAUDE.md`. Honest status: the local leg is verified end-to-end in a browser; the OIDC sign-in leg is verified by unit tests (probe/teardown scoping, actor branching) + manual local-prod, not by an automated browser sign-in. > The adoption follow-on (A5 — connect+adopt, A6 — offline-doc durability/D1, A7adopt — E2E) lives in `2026-07-06-hub-client-local-project-adoption.md`, `conditional-blocks` on `bd-10bdjmjb`. diff --git a/hub-client/LOCAL-FIRST.md b/hub-client/LOCAL-FIRST.md new file mode 100644 index 000000000..a94845730 --- /dev/null +++ b/hub-client/LOCAL-FIRST.md @@ -0,0 +1,59 @@ +# Local-first documents & connecting to a hub + +Quarto Hub opens straight into a usable editor — **no sign-in required**. Your +projects are real Automerge documents that live in your browser's local +storage. Signing in to a hub is optional, and only needed when you want to +sync a project across devices or collaborate with other people. + +> This is about the **document / account model** you see as a user. For how the +> app itself keeps working with no network (asset/service-worker caching), see +> [`OFFLINE.md`](./OFFLINE.md). + +## Working locally (no account) + +When you open Quarto Hub for the first time: + +- The **project selector** appears immediately — there is no login gate. +- **Create New Project** and **Import from ZIP** create a project that lives + entirely in your browser. No sync server is contacted. +- Everything you edit is saved to local storage as you go, so your work + survives a reload or closing the tab. Your edits are attributed to a stable + local author ("You") that stays consistent across reloads. + +A local project is private to this browser. It is not uploaded anywhere and is +not visible on any other device until you publish it to a hub. + +## Connecting to a hub + +Use the **Connect to a hub** control in the header (top-right) when you want to +sync or collaborate. It: + +1. Signs you in (if you don't already have a session), then +2. Lets you open or create projects hosted on the hub server. + +Once you're signed in, the header shows **"Signed in as _you_ · Sign out"**. +New projects you create while connected to a hub are hosted on that hub and can +be shared; projects you create while working locally stay local. + +Signing out (or a session expiring) never disturbs a local project — only +hub-backed projects need a valid session, and losing one simply returns you to +the project list. + +## What requires a hub + +| Action | Local (no account) | Connected to a hub | +| --- | --- | --- | +| Create / edit / preview a project | ✅ | ✅ | +| Persist across reloads (same browser) | ✅ | ✅ | +| Sync across your own devices | — | ✅ | +| Real-time collaboration with others | — | ✅ | +| Share a project link | — | ✅ | + +## Notes + +- **Connect to a hub** (header, account-level) is different from **Connect to + Project** (in the actions row), which joins an *existing* hub project by its + document id and sync-server URL. +- Publishing an existing **local** project up to a hub (keeping its history) is + a separate, planned feature — for now, local projects stay local and hub + projects are created directly on the hub. diff --git a/hub-client/README.md b/hub-client/README.md index 4514d3140..225dbcc93 100644 --- a/hub-client/README.md +++ b/hub-client/README.md @@ -2,6 +2,11 @@ Web frontend for Quarto Hub - a collaborative document editor using Quarto's WASM rendering engine. +Quarto Hub is **local-first**: it opens straight into a usable editor with no +sign-in, and projects live in your browser until you choose to connect to a +hub for sync and collaboration. See [`LOCAL-FIRST.md`](./LOCAL-FIRST.md) for the +user-facing model and [`OFFLINE.md`](./OFFLINE.md) for the offline asset cache. + ## Prerequisites - Node.js 18+ diff --git a/hub-client/e2e/local-first.spec.ts b/hub-client/e2e/local-first.spec.ts new file mode 100644 index 000000000..55169ff85 --- /dev/null +++ b/hub-client/e2e/local-first.spec.ts @@ -0,0 +1,60 @@ +/** + * Local-first / connection-gated auth E2E (A7v1, bd-a1gpy16v). + * + * Verifies the headline of the connection-gated local-first work through a + * real browser: with no IdP configured, the SPA opens straight into a usable + * project selector (no login gate), a project can be created fully locally + * (no sync server), and it persists across a reload. The account-level + * control offers "Connect to a hub" rather than gating the whole app. + * + * The hub-connect leg (sign in, open/create a hub project) requires a live + * OIDC provider and is verified manually — see the plan's A7v1 notes. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Local-first (connection-gated auth)', () => { + test('opens with no login gate and offers "Connect to a hub"', async ({ page }) => { + await page.goto('/'); + + // The project selector renders immediately — no LoginScreen gate. + await expect(page.getByRole('heading', { name: 'Your Projects' })).toBeVisible(); + + // The account-level control offers connecting to a hub (disconnected + // state), not a full-screen sign-in gate. + await expect(page.getByRole('button', { name: /^connect to a hub$/i })).toBeVisible(); + + // The local-first create/import actions are available with no auth. + await expect(page.getByRole('button', { name: /create new project/i })).toBeVisible(); + }); + + test('creates a local project offline and persists it across a reload', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Your Projects' })).toBeVisible(); + + // Open the create form and pick the first available project type. + await page.getByRole('button', { name: /create new project/i }).click(); + const typeSelect = page.locator('#projectType'); + await expect(typeSelect).toBeVisible(); + await typeSelect.selectOption({ index: 0 }); + + // The create form must not ask for a sync server — Create is local. + await expect(page.getByLabel(/sync server url/i)).toHaveCount(0); + + const title = `Local Project ${Date.now()}`; + await page.locator('#projectTitle').fill(title); + await page.getByRole('button', { name: /^create project$/i }).click(); + + // We navigate into the newly-created project (the selector is replaced + // by the editor). The project id lands in the URL hash (route: /#/p/). + await expect(page).toHaveURL(/#\/p\//, { timeout: 30000 }); + + // Reload: the local project set + entry live in IndexedDB, so the + // project must still be listed with no server round-trip. + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Your Projects' })).toBeVisible(); + await expect(page.getByText(title)).toBeVisible({ timeout: 30000 }); + }); +}); From 175508944b712835754140a0c5a5f3928c251c45 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:35:27 +0100 Subject: [PATCH 07/16] hub-client: prompt sign-in when opening a hub project logged off (bd-u4p8xhdc) Logged-off selection of a hub project (with a syncServer) resolved a null actor (401) and the open path returned silently. Extract resolveActorForOpen: local projects always open; a hub project needing a session we don't have now prompts sign-in instead of a silent no-op. --- ...hub-client-connection-gated-local-first.md | 15 +++++ hub-client/src/App.tsx | 19 ++++-- hub-client/src/services/openActor.test.ts | 63 +++++++++++++++++++ hub-client/src/services/openActor.ts | 43 +++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 hub-client/src/services/openActor.test.ts create mode 100644 hub-client/src/services/openActor.ts diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index aba13b1d1..ed0205ab7 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -110,6 +110,21 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. > The adoption follow-on (A5 — connect+adopt, A6 — offline-doc durability/D1, A7adopt — E2E) lives in `2026-07-06-hub-client-local-project-adoption.md`, `conditional-blocks` on `bd-10bdjmjb`. +## Follow-up fixes (post-A4) + +- **Logged-off hub-project open was a silent no-op** (interactive testing). With + the gate removed, a logged-off user reaches the selector and can click a hub + project (one with a `syncServer`), but `resolveActorId` returned `null` + (401) and the open path just `return`ed — nothing happened, no feedback. + Fix: extracted `resolveActorForOpen` (`src/services/openActor.ts`) — local + projects always open under the local actor; a hub project whose actor + resolves to `null` now fires an `onNeedsSignIn` callback (App shows the + sign-in screen + a "Sign in to open this hub project" message) instead of + failing silently. `undefined` (auth-disabled/insecure hub) still opens with + no prompt. Tests: `openActor.test.ts` (4 cases). *(Offline-read of a + cached hub project while logged off is intentionally NOT done here — that is + offline-durability / D1 territory.)* + ## Non-goals - **v1 does not publish an existing local project up to a hub** — that is the adoption follow-on plan (`2026-07-06-hub-client-local-project-adoption.md`), deferred behind D1 (`bd-10bdjmjb`). - Does **not** change server-side auth enforcement; an auth-required hub still 401s unauthenticated connections. diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index d5247478d..ba9bd12af 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -39,6 +39,7 @@ import { useAuth } from './hooks/useAuth'; import { useAuthProbe } from './hooks/useAuthProbe'; import { useExecutionChannel } from './hooks/useExecutionChannel'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; +import { resolveActorForOpen as resolveActorForOpenImpl } from './services/openActor'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; import { resolveSyncServerUrl } from './utils/routing'; import './App.css'; @@ -151,13 +152,21 @@ function App() { [triggerRefresh], ); - // Choose the authoring actor for a project when opening it. A local - // project (no sync server) is authored under the stable per-browser local - // actor — no server round-trip. A hub project uses the server-derived HMAC - // actor via `resolveActorId` (bd-gxz6tqbk / bd-u4p8xhdc). + // Choose the authoring actor for a project when opening it. A local project + // (no sync server) is authored under the stable per-browser local actor — + // no server round-trip. A hub project uses the server-derived HMAC actor; + // if that needs a session we don't have (401 → null), prompt sign-in rather + // than letting the open fail silently (bd-gxz6tqbk / bd-u4p8xhdc). const resolveActorForOpen = useCallback( (indexDocId: string, syncServer: string): Promise => - syncServer ? resolveActorId(indexDocId) : getOrCreateLocalActor(), + resolveActorForOpenImpl(indexDocId, syncServer, { + getLocalActor: getOrCreateLocalActor, + resolveHubActor: resolveActorId, + onNeedsSignIn: () => { + setConnectionError('Sign in to open this hub project.'); + setShowLogin(true); + }, + }), [resolveActorId], ); diff --git a/hub-client/src/services/openActor.test.ts b/hub-client/src/services/openActor.test.ts new file mode 100644 index 000000000..d41c25781 --- /dev/null +++ b/hub-client/src/services/openActor.test.ts @@ -0,0 +1,63 @@ +/** + * resolveActorForOpen (bd-u4p8xhdc follow-up). + * + * Bug: with connection-gated auth, selecting a HUB project (one with a sync + * server) while logged off silently did nothing — the actor resolve returned + * null (401) and the open path just `return`ed with no feedback. A local + * project must still open with no session; a hub project that needs a session + * we don't have must PROMPT sign-in, not fail silently. + * + * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { resolveActorForOpen } from './openActor'; + +describe('resolveActorForOpen', () => { + it('opens a local project (no sync server) under the local actor, no sign-in prompt', async () => { + const onNeedsSignIn = vi.fn(); + const actor = await resolveActorForOpen('automerge:local', '', { + getLocalActor: async () => 'localactor00000000000000000000aa', + resolveHubActor: async () => null, // must not be consulted for a local project + onNeedsSignIn, + }); + expect(actor).toBe('localactor00000000000000000000aa'); + expect(onNeedsSignIn).not.toHaveBeenCalled(); + }); + + it('opens a hub project with the resolved HMAC actor when signed in', async () => { + const onNeedsSignIn = vi.fn(); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { + getLocalActor: async () => 'localactor', + resolveHubActor: async () => 'hubactor11111111111111111111bbbb', + onNeedsSignIn, + }); + expect(actor).toBe('hubactor11111111111111111111bbbb'); + expect(onNeedsSignIn).not.toHaveBeenCalled(); + }); + + it('prompts sign-in when a hub project needs a session we do not have (401 → null)', async () => { + const onNeedsSignIn = vi.fn(); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { + getLocalActor: async () => 'localactor', + resolveHubActor: async () => null, // 401/403 from /auth/actor + onNeedsSignIn, + }); + // Still returns null so the caller abandons the open... + expect(actor).toBeNull(); + // ...but surfaces the sign-in prompt instead of a silent no-op. + expect(onNeedsSignIn).toHaveBeenCalledTimes(1); + }); + + it('opens a hub project with no actor when auth is disabled (undefined, no prompt)', async () => { + const onNeedsSignIn = vi.fn(); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { + getLocalActor: async () => 'localactor', + resolveHubActor: async () => undefined, // AUTH_ENABLED false → insecure/no-auth hub + onNeedsSignIn, + }); + expect(actor).toBeUndefined(); + expect(onNeedsSignIn).not.toHaveBeenCalled(); + }); +}); diff --git a/hub-client/src/services/openActor.ts b/hub-client/src/services/openActor.ts new file mode 100644 index 000000000..b28094110 --- /dev/null +++ b/hub-client/src/services/openActor.ts @@ -0,0 +1,43 @@ +/** + * Resolve the authoring actor to open a project with, and decide whether the + * open must be gated behind a hub sign-in. + * + * Connection-gated auth (bd-u4p8xhdc): a local project (no sync server) always + * opens, authored under the stable per-browser local actor. A hub project + * (has a sync server) uses the server-derived HMAC actor, which requires a + * valid session: + * + * - `string` → actor resolved; open with it. + * - `undefined` → auth is disabled (e.g. an insecure/no-auth hub); open with + * no explicit actor. No sign-in needed. + * - `null` → the hub rejected us (401/403) — we need a session we don't + * have. `onNeedsSignIn` fires so the caller can surface the + * sign-in prompt instead of a silent no-op, and `null` is + * returned so the caller still abandons this open attempt. + */ +export interface OpenActorDeps { + /** Stable per-browser local actor for local-only projects. */ + getLocalActor: () => Promise; + /** Server-derived HMAC actor for a hub project (three-valued; see above). */ + resolveHubActor: (indexDocId: string) => Promise; + /** Called when a hub open needs a session we don't have (prompt sign-in). */ + onNeedsSignIn: () => void; +} + +export async function resolveActorForOpen( + indexDocId: string, + syncServer: string, + deps: OpenActorDeps, +): Promise { + if (!syncServer) { + // Local-only project — always openable, no session required. + return deps.getLocalActor(); + } + const actor = await deps.resolveHubActor(indexDocId); + if (actor === null) { + // Hub project needs a session we don't have. Surface the sign-in prompt + // rather than letting the caller's `=== null` guard fail silently. + deps.onNeedsSignIn(); + } + return actor; +} From d1b0ea9b63431fb1de4bf7ad44b28c9a3e2ef4ed Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:56:23 +0100 Subject: [PATCH 08/16] plan: offline read+write of cached hub projects (epic bd-xxjy9yfp) Scopes full offline editing of cached hub projects as the adoption forward-switch+display-bridge run recurrently on each reconnect (local actor offline, HMAC on reconnect). Coordinated with D1 durability (bd-10bdjmjb); supersedes the interim prompt-sign-in for cached projects. --- ...-hub-client-offline-cached-hub-projects.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md diff --git a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md new file mode 100644 index 000000000..f9580cac8 --- /dev/null +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -0,0 +1,193 @@ +# hub-client: offline read + write of cached hub projects + +**Status:** proposed · **deferred/coordinated** — shares the actor-bridge with +the adoption follow-on and is gated on the same D1 offline-doc durability work +(`bd-10bdjmjb`). +**Date:** 2026-07-15 +**Part of:** the auth-reshape path — umbrella +`claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. +Fast-follow to plan 1 (connection-gated local-first, +`2026-07-06-hub-client-connection-gated-local-first.md`) and a sibling of the +adoption follow-on (`2026-07-06-hub-client-local-project-adoption.md`), with +which it shares the actor-ID reconciliation design. + +## Overview + +Plan 1 shipped: local-first documents, gate removal, and a "Connect to a hub" +control. In v1, selecting a **hub** project (one with a `syncServer`) while +logged off / offline first failed silently, then — as an interim fix — prompted +sign-in (`resolveActorForOpen` → `onNeedsSignIn`, commit `82b19375`). + +This plan replaces that interim behavior with the real feature: **a cached hub +project opens offline and is fully editable; edits sync to the hub the moment +the user reconnects (signs in / comes online), with coherent one-human +authorship across the whole online→offline→online timeline.** + +### Why this is not the rejected alternative + +The adoption plan (`…-local-project-adoption.md`, "Rejected alternative", lines +45–52) rejected *"keep the local actor forever, never switch to HMAC"* for hub +projects — because a client-chosen actor is spoofable and loses cross-device +stability. **This plan does not do that.** It uses the persisted per-browser +local actor *only during offline windows* and performs the adoption-style +**forward switch to the HMAC actor + `identities` display-bridge on every +reconnect**. The local actor is transient authorship that is always reconciled +forward; the hub's HMAC-actor authorship model is preserved. In other words: +**this is the adoption forward-switch+bridge (adoption steps 8–9) generalized to +fire on each online transition, not just once at first publish.** + +## Current architecture we build on (all shipped in plan 1) + +- **Storage-only vs networked Repo** via `buildRepo({ syncServer? })` + (`ts-packages/quarto-sync-client/src/client.ts`). A hub project builds a + networked Repo; when the WS upgrade 401s (logged off) or times out (offline), + `connect` already **degrades to offline-from-IndexedDB** (loads cached docs, + `isOnline=false`). The only thing blocking an offline hub open today is the + client-side `resolveActorId` → `null` gate, not the sync layer. +- **Persisted per-browser local actor** `getOrCreateLocalActor()` + (`hub-client/src/services/userSettings.ts`, plan 1 A3). +- **`applyActorId(handle, actorId)`** switches future authorship on any handle, + history-preserving (`client.ts:475`), already called on every `findDoc`. +- **`GET /auth/actor?project=`** returns the stable HMAC actor for any + project id with no project validation (`server.rs:788-807`) — works for a + project the server has seen (unlike adoption's never-seen case). +- **Authorship display** via `IndexDocument.identities[actorId] = {name,color}` + (`quarto-automerge-schema`, `setIdentity`), fallback `actor.slice(0,8)` + (`useAttribution.ts`). "Me" = the Automerge actor. +- **Server accepts changes from any authenticated peer** — `AuditAccessPolicy. + is_allowed` is peer/cookie-based and always returns true for an authenticated + peer (`crates/quarto-hub/src/access_policy.rs`); it does **not** validate that + incoming changes carry the HMAC actor. **Confirmed:** offline edits authored + under the local actor will NOT be rejected on sync — the actor only affects + authorship display, not acceptance. + +## Design: local↔HMAC actor oscillation with a persistent display-bridge + +For a hub project (`syncServer` set): + +**Opening offline / logged off (no session):** +1. Do NOT abandon at the actor step. Resolve the actor as: HMAC `stableActor` + if a session yields one; otherwise fall back to the persisted **local + actor** (`getOrCreateLocalActor()`). +2. `connect(syncServer, indexDocId, , …)` — the WS 401s/offline + and the sync client degrades to offline-from-cache (existing behavior). The + project opens read+write from IndexedDB. +3. Offline edits author under the local actor; ensure `identities[localActor] = + {name,color}` is written so they display as this human immediately. + +**On reconnect (sign-in / online) for that project:** +4. `GET /auth/actor?project=` → `stableActor`. +5. `applyActorId(stableActor)` on all handles → future edits carry the HMAC + actor again. +6. **Bridge the offline window (display only):** `identities[stableActor] = + identities[localActor] = {name: userName, color}`. Offline history + online + edits now display as one human. No change is rewritten (changes are + immutable — see the adoption plan). +7. The network adapter (re)connects and the offline edits sync up via normal + automerge sync. + +**Steady online state:** unchanged from today — HMAC actor, live sync. + +This is the adoption `local→connected` design (steps 6–11 there) applied to an +already-hub project across a connectivity gap, run **every** time connectivity +returns rather than once. + +## Phases (TDD-first) — coordinate with D1 (`bd-10bdjmjb`) + +- [ ] **B0 — Test scaffolding.** Fixtures: a cached hub project (created online + against a fake/real hub, then the peer dropped) reopened offline; an offline + edit; a reconnect that flushes the edit up; authorship assertions across the + timeline. Extend the `openActor` seam. Write the failing tests for B1–B3. +- [ ] **B1 — Open cached hub project offline (supersede prompt-sign-in).** + `resolveActorForOpen` (and the `openActor` helper): for a hub project with no + resolvable HMAC actor, fall back to the local actor and open from cache + instead of firing `onNeedsSignIn`. Keep a genuine "never-cached + offline" + case surfacing a clear "can't open — not cached, and you're offline" message + (that one really can't open). Tests: cached hub doc opens offline read+write; + never-cached hub doc offline reports the precise reason. +- [ ] **B2 — Offline authorship under the local actor.** Author offline edits + under the local actor; write `identities[localActor]`. Tests: offline edits + attribute to this human (not an 8-hex stub) and persist across reload. +- [ ] **B3 — Reconnect switch + display bridge + sync-up.** On sign-in/online + for the project: fetch `stableActor`, `applyActorId`, bridge `identities` + (steps 4–7), (re)attach/reactivate the network adapter, flush offline edits. + Reuse the adoption A5 mechanics. Tests: future edits carry `stableActor`; + offline + online history display as one human; offline edits reach the (fake) + hub after reconnect. +- [ ] **B4 — Durability of offline edits (D1 coordination).** Ensure offline + edits to *existing cached* docs reliably sync on reconnect. Determine whether + this shares D1's announce-on-connect fix (`bd-10bdjmjb`) or is already covered + by normal automerge sync for existing docs. Tests: edit N cached docs offline, + reconnect, assert all N updates reach the hub. +- [ ] **B5 — End-to-end verification + docs.** Real browser: open a hub project + online, go offline (or sign out), edit, reconnect/sign-in, confirm the edits + reach the hub and authorship reads as one human in the UI/DevTools. Record + exact steps + observed output per the repo end-to-end policy. Update + `hub-client/LOCAL-FIRST.md` (the table currently says sync/collab are + hub-only; add the offline-cached-hub-project row). + +## Interaction with the shipped v1 + +- **Supersedes** the interim prompt-sign-in behavior for *cached* hub projects + (commit `82b19375`). Prompt-sign-in remains correct for a hub project that is + NOT cached locally and cannot be opened offline. The `openActor` seam and its + unit tests are the extension point — B1 turns the "hub + null actor" branch + from "prompt" into "open-from-cache-under-local-actor when cached". + +## Non-goals + +- Does **not** re-attribute past Automerge changes (impossible) — attribution is + bridged at the display layer only (shared with adoption). +- Does **not** change server-side auth/acceptance. Access stays cookie/peer-based + (`access_policy.rs`); an auth-required hub still 401s the live WS, which is + exactly what triggers offline-from-cache. +- Does **not** change how the browser authenticates (Part B / PKCE plan). + +## Risks & open questions + +- **D1 gating (`bd-10bdjmjb`, in progress):** confirm whether syncing offline + edits to *existing* cached docs needs the D1 announce-on-connect fix or is + already covered by normal automerge sync (creation was the D1 case). This + determines whether B can land ahead of D1. +- **Spoofable offline actor:** offline edits under a client-chosen local actor + are display-bridged, not server-vouched — the same trust model plan 1 already + accepts for local projects. Server acceptance is unaffected (peer/cookie-based). + Document it; do not pretend offline edits carry server-trusted attribution. +- **Actor-bridge timing (from adoption):** the bridge (step 6) must run before + the first post-reconnect change is observed by collaborators, or a peer + briefly sees the local-actor history as an unknown author. +- **Stale-cache / large divergence UX:** long offline edits vs. concurrent + collaborator edits merge via CRDT; consider surfacing "syncing offline changes" + and conflict-heavy states. Likely a follow-up, not v1 of this plan. +- **Multiple browsers offline:** each browser has its own local actor; each is + bridged on that browser's reconnect. Authorship stays one-human per browser; + cross-browser it relies on the HMAC actor once online (unchanged). + +## Braid strand structure + +- Epic **`bd-xxjy9yfp`** (this plan), `related` to Epic `bd-o3if4hrm` + (connection-gated v1) and `bd-10bdjmjb` (D1). Phase strands (parent-child): + - `bd-ysusqcb3` — B0 (test scaffolding) + - `bd-qklxdkwh` — B1 (open cached hub offline; supersede prompt-sign-in) — blocked by B0 + - `bd-ab44wv07` — B2 (offline authorship under local actor) — blocked by B1 + - `bd-g5apu5bm` — B3 (reconnect switch + bridge + sync-up) — blocked by B2, `related` to `bd-10bdjmjb` + - `bd-7drrqapw` — B4 (durability / D1 coordination) — blocked by B3, `related` to `bd-10bdjmjb` + - `bd-qe84xjd6` — B5 (E2E + docs) — blocked by B4 +- B3/B4 are `related` (not hard-blocked) to D1 because **B4's job is to + determine** whether D1's announce-on-connect fix is required; escalate to + `conditional-blocks` if B4 finds it is. + +## References +### Plans +- `2026-07-06-hub-client-connection-gated-local-first.md` — plan 1 (foundation + the interim prompt-sign-in fix this supersedes). +- `2026-07-06-hub-client-local-project-adoption.md` — adoption follow-on (shared actor-bridge design; its open question line 67 is this feature's seed). +- `2026-06-12-sync-client-offline-race.md` — D1 durability. +- `2026-05-25-reactji-authorship-q2-preview.md` — authorship model. + +### Key files +- `hub-client/src/services/openActor.ts` (+ `.test.ts`) — the actor-open seam (B1). +- `hub-client/src/services/userSettings.ts` — `getOrCreateLocalActor` (B2). +- `ts-packages/quarto-sync-client/src/client.ts` — `buildRepo`, `connect` offline degrade, `applyActorId`, `flush` (B1–B4). +- `ts-packages/quarto-automerge-schema/src/index.ts` — `setIdentity`/`identities` bridge (B2–B3). +- `hub-client/src/App.tsx` — open paths + reconnect wiring (B1, B3). +- `crates/quarto-hub/src/access_policy.rs` — confirms peer/cookie-based acceptance (no actor gate). From 04de839bee4e6e6f70a97148c0f9965ce39c3b96 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:58:37 +0100 Subject: [PATCH 09/16] sync-client: offline-cached-hub test scaffolding (B0, bd-ysusqcb3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable timeline fixtures for the offline-cached-hub epic (bd-xxjy9yfp): test-hub gains setHolding/dropConnections (stable-URL online→offline→online), cached-hub.ts composes them with fake-indexeddb, and a green baseline test exercises the full timeline. The baseline passing answers B4's open question: an offline edit to an existing cached doc syncs up on reconnect via normal automerge sync, no D1 fix needed. B1-B3 behavioral specs are staged as it.todo. --- ...-hub-client-offline-cached-hub-projects.md | 41 ++++- hub-client/src/services/openActor.test.ts | 9 + .../src/cached-hub-offline.test.ts | 122 +++++++++++++ .../quarto-sync-client/src/cached-hub.ts | 167 ++++++++++++++++++ .../quarto-sync-client/src/test-hub.ts | 30 +++- 5 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts create mode 100644 ts-packages/quarto-sync-client/src/cached-hub.ts diff --git a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md index f9580cac8..74da59056 100644 --- a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -94,10 +94,34 @@ returns rather than once. ## Phases (TDD-first) — coordinate with D1 (`bd-10bdjmjb`) -- [ ] **B0 — Test scaffolding.** Fixtures: a cached hub project (created online +- [x] **B0 — Test scaffolding.** Fixtures: a cached hub project (created online against a fake/real hub, then the peer dropped) reopened offline; an offline edit; a reconnect that flushes the edit up; authorship assertions across the timeline. Extend the `openActor` seam. Write the failing tests for B1–B3. + - **Done:** Reusable green scaffolding, mirroring how A0 landed (fixtures + committed green; each phase's red test lands with its own implementation — + a red test is never committed alone, per the A1 precedent). + - **`test-hub.ts`** gained `setHolding(hold)` + `dropConnections()` so a + single hub on a **stable URL** models online→offline→online (drop live + sockets + hold new upgrades = offline; `setHolding(false)` = reconnect). + `releaseUpgrades()` kept as the `setHolding(false)` alias for the D1 + tests. + - **`cached-hub.ts`** (new) composes test-hub + fake-indexeddb into the + timeline: `createSyncedHubProject` (create online, confirm the hub holds + both docs), `goOffline`/`goOnline`, `reopenCachedOffline` (fresh client = + reload, degrades to offline-from-cache), `waitForHubFileText` + (server-side ground truth), `readIdentities`. + - **`cached-hub-offline.test.ts`** (new) — green baseline exercising the + full timeline at the sync-client layer, plus `it.todo` specs for B1–B3. + `openActor.test.ts` gained the two B1 `it.todo` specs at the seam. + - **Empirical finding (answers B4's open question for *existing* docs):** the + baseline **passes** — an offline edit to an already-synced cached doc + reaches the hub on reconnect via normal automerge sync, with **no** D1 + announce-on-connect fix (`bd-10bdjmjb`, which was about *newly created* + docs). B4 is therefore *not* gated on D1 for the edit-existing-doc case; + see the updated risk below. The remaining work is purely **app-layer** + (actor resolution B1 + reconnect display-bridge B3) — the sync layer + already opens offline read+write and syncs up. - [ ] **B1 — Open cached hub project offline (supersede prompt-sign-in).** `resolveActorForOpen` (and the `openActor` helper): for a hub project with no resolvable HMAC actor, fall back to the local actor and open from cache @@ -119,6 +143,11 @@ returns rather than once. this shares D1's announce-on-connect fix (`bd-10bdjmjb`) or is already covered by normal automerge sync for existing docs. Tests: edit N cached docs offline, reconnect, assert all N updates reach the hub. + - **B0 finding:** for a *single* existing cached doc the answer is **already + covered** — the B0 baseline flushes an offline edit to the hub on reconnect + with no D1 fix. B4 shrinks to widening this to *N* docs (and a + newly-created-offline doc under an existing project, which *is* the D1 + case) and confirming it holds under the real hub-client reconnect wiring. - [ ] **B5 — End-to-end verification + docs.** Real browser: open a hub project online, go offline (or sign out), edit, reconnect/sign-in, confirm the edits reach the hub and authorship reads as one human in the UI/DevTools. Record @@ -145,10 +174,12 @@ returns rather than once. ## Risks & open questions -- **D1 gating (`bd-10bdjmjb`, in progress):** confirm whether syncing offline - edits to *existing* cached docs needs the D1 announce-on-connect fix or is - already covered by normal automerge sync (creation was the D1 case). This - determines whether B can land ahead of D1. +- **D1 gating (`bd-10bdjmjb`, in progress):** ~~confirm whether syncing offline + edits to *existing* cached docs needs the D1 announce-on-connect fix~~ + **Resolved by B0:** editing an *existing* cached doc offline syncs up on + reconnect via normal automerge sync — no D1 fix needed, so **B can land ahead + of D1**. D1 still gates the *create-offline-under-an-existing-project* case + (a genuinely new doc), which B4 must keep distinct. - **Spoofable offline actor:** offline edits under a client-chosen local actor are display-bridged, not server-vouched — the same trust model plan 1 already accepts for local projects. Server acceptance is unaffected (peer/cookie-based). diff --git a/hub-client/src/services/openActor.test.ts b/hub-client/src/services/openActor.test.ts index d41c25781..64b75a65d 100644 --- a/hub-client/src/services/openActor.test.ts +++ b/hub-client/src/services/openActor.test.ts @@ -60,4 +60,13 @@ describe('resolveActorForOpen', () => { expect(actor).toBeUndefined(); expect(onNeedsSignIn).not.toHaveBeenCalled(); }); + + // B1 (bd-qklxdkwh, epic bd-xxjy9yfp) supersedes the prompt-sign-in + // behavior for *cached* hub projects: a logged-off open of a cached hub + // project falls back to the local actor and opens from cache instead of + // firing onNeedsSignIn; only a genuinely never-cached + offline project + // surfaces a precise "can't open" reason. These land red→green with B1's + // seam extension (cache-awareness in the openActor deps). + it.todo('B1: cached hub project + null HMAC actor opens under the local actor (no prompt)'); + it.todo('B1: never-cached hub project + offline reports a precise "not cached" reason'); }); diff --git a/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts new file mode 100644 index 000000000..d5d7ef999 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts @@ -0,0 +1,122 @@ +/** + * Offline-cached-hub-project timeline — sync-client baseline (B0, + * bd-ysusqcb3; epic bd-xxjy9yfp). + * + * These are the *scaffolding* tests for the offline-cached feature. They + * establish, at the sync-client layer, what already works today so the + * B1–B3 app-layer work (actor resolution + reconnect display-bridge) is + * localized — the same way offline-creation.test.ts localized D1. + * + * The green assertions below are the harness B1–B3 build on. The + * `it.todo` entries enumerate the behavioral specs each later phase adds + * (with its implementation, per the team's red-then-green-per-phase + * cadence — a red test is never committed alone). + * + * Plan: claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; + +import { startTestHub, type TestHub } from './test-hub.js'; +import { + createSyncedHubProject, + goOffline, + goOnline, + reopenCachedOffline, + waitForHubFileText, +} from './cached-hub.js'; + +// A stable per-browser local actor stand-in (32 hex = a valid Automerge +// actor id), plus a hub-side "HMAC" actor for the online create. Both +// must be valid hex — Automerge rejects a non-hex actor id. +const LOCAL_ACTOR = 'ffff0000ffff0000ffff0000ffff0000'; +const HUB_ACTOR = 'aaaa1111bbbb2222cccc3333dddd4444'; + +function freshIndexedDb() { + Object.defineProperty(globalThis, 'indexedDB', { + value: new IDBFactory(), + writable: true, + }); +} + +let hub: TestHub; + +beforeEach(async () => { + freshIndexedDb(); + hub = await startTestHub(); +}); + +afterEach(async () => { + await hub.stop(); +}); + +describe('offline-cached hub project (sync-client baseline)', () => { + it('reopens a cached hub project offline read+write and flushes edits on reconnect', async () => { + // The whole online → offline → reconnect timeline at the sync-client + // layer. What passes here is what B1–B3 build on; it localizes the + // remaining work to the app layer (actor resolution + display bridge). + const project = await createSyncedHubProject(hub, { + path: 'notes.qmd', + content: 'online body\n', + actor: HUB_ACTOR, + screenName: 'Alice', + color: '#3366cc', + }); + + // The connection is lost mid-session; the URL stays the same. + goOffline(hub); + + // A fresh client (a browser reload) reopens the cached project while + // the hub is unreachable. The sync client degrades to + // offline-from-cache — no peer, but the file reads back. + const reopened = await reopenCachedOffline(hub, project, { + actor: LOCAL_ACTOR, + screenName: 'You', + color: '#cc6633', + }); + expect(reopened.files.map((f) => f.path)).toContain('notes.qmd'); + expect(reopened.client.getFileContent('notes.qmd')).toBe('online body\n'); + expect(reopened.client.getSyncDiagnostics().connectedPeers).toBe(0); + + // Offline edit: it persists locally with no peer. + reopened.client.updateFileContent('notes.qmd', 'edited offline\n'); + await reopened.client.flush(); + expect(reopened.client.getFileContent('notes.qmd')).toBe('edited offline\n'); + expect(reopened.client.getSyncDiagnostics().connectedPeers).toBe(0); + + // Reconnect: the adapter re-establishes the peer and the offline edit + // syncs up. This answers B4's open question for *existing* docs — an + // edit to an already-synced doc reaches the hub via normal automerge + // sync, without the D1 announce-on-connect fix (bd-10bdjmjb, which was + // about *newly created* docs). + goOnline(hub); + const reached = await waitForHubFileText(hub, project.fileDocId, 'edited offline\n', 15000); + expect(reached, 'offline edit must reach the hub after reconnect').toBe(true); + + await reopened.client.disconnect(); + }, 30000); + + // ── Behavioral specs the later phases implement (red→green with impl) ── + + // B1 (bd-qklxdkwh) — the app-level open path currently prompts sign-in + // for a logged-off hub open (openActor → onNeedsSignIn). B1 makes a + // *cached* hub project open under the local actor instead; only a + // genuinely never-cached + offline project reports a precise + // "can't open" reason. (Unit-tested at the openActor seam; see + // openActor.test.ts.) + it.todo('B1: cached hub project opens offline under the local actor (no sign-in prompt)'); + it.todo('B1: never-cached hub project offline reports a precise "not cached" reason'); + + // B2 (bd-ab44wv07) — offline edits author under the local actor and + // write identities[localActor] so they display as this human, not an + // 8-hex stub, and persist across reload. + it.todo('B2: offline edits author under the local actor with an identities row'); + + // B3 (bd-g5apu5bm) — on reconnect: fetch the HMAC stableActor, + // applyActorId to all handles, bridge identities[stableActor] = + // identities[localActor], so the offline window and online edits + // display as one human. + it.todo('B3: reconnect switches to the HMAC actor and bridges identities to one human'); +}); diff --git a/ts-packages/quarto-sync-client/src/cached-hub.ts b/ts-packages/quarto-sync-client/src/cached-hub.ts new file mode 100644 index 000000000..9c0ea6404 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/cached-hub.ts @@ -0,0 +1,167 @@ +/** + * Reusable fixtures for the offline-cached-hub-project timeline + * (epic bd-xxjy9yfp, B0 bd-ysusqcb3). + * + * Models the full online → offline → reconnect story a hub project goes + * through when the user loses connectivity and keeps editing: + * + * 1. {@link createSyncedHubProject} — create a project online against a + * {@link TestHub} and confirm the hub holds both the index and file + * docs, so the project is genuinely cached client-side (IndexedDB) AND + * server-side. + * 2. {@link goOffline} — drop the live sockets and hold new upgrades, so + * the *same URL* is now unreachable (a mid-session network loss). + * 3. {@link reopenCachedOffline} — a fresh sync client (a browser + * "reload") reopens the cached project; the peer wait times out and + * the client degrades to offline-from-cache. + * 4. {@link goOnline} — accept upgrades again; the client's adapter + * reconnects and offline edits sync up. + * + * Built on `startTestHub` (its `setHolding`/`dropConnections` give a + * stable-URL offline window) + `fake-indexeddb` (a fresh `SyncClient` + * re-reads the same cache). Tests own the fake-indexeddb reset. + */ + +import type { DocumentId } from '@automerge/automerge-repo'; + +import { createSyncClient, type SyncClient } from './client.js'; +import type { SyncClientCallbacks } from './types.js'; +import type { TestHub } from './test-hub.js'; + +/** A sync client plus the file-added / identity notifications it received. */ +export interface RecordingClient { + client: SyncClient; + /** Paths passed to `onFileAdded`, in order. */ + filesAdded: string[]; +} + +/** A `SyncClient` with no-op callbacks that records added file paths. */ +export function makeRecordingClient(): RecordingClient { + const filesAdded: string[] = []; + const callbacks: SyncClientCallbacks = { + onFileAdded: (path) => { + filesAdded.push(path); + }, + onFileChanged: () => {}, + onBinaryChanged: () => {}, + onFileRemoved: () => {}, + }; + return { client: createSyncClient(callbacks), filesAdded }; +} + +/** The `identities` map (actorId → {name,color}) from a client's index doc. */ +export function readIdentities( + client: SyncClient, +): Record { + const doc = client.getIndexHandle()?.doc(); + return (doc?.identities ?? {}) as Record; +} + +export interface CachedHubProject { + indexDocId: string; + fileDocId: string; + path: string; + content: string; +} + +/** + * Create a project online against `hub` and wait until the hub actually + * holds both docs — the project is then cached on both ends. The creator + * client is disconnected before returning; callers reopen with a fresh + * client to model a reload. + */ +export async function createSyncedHubProject( + hub: TestHub, + opts: { + path?: string; + content?: string; + actor?: string; + screenName?: string; + color?: string; + } = {}, +): Promise { + const path = opts.path ?? 'doc.qmd'; + const content = opts.content ?? 'online body\n'; + const { client } = makeRecordingClient(); + const created = await client.createNewProject( + { + syncServer: hub.url, + files: [{ path, content, contentType: 'text' }], + storage: 'indexeddb', + // Create while the peer is connected so the docs flush to the hub + // immediately (no background-sync race). + peerTimeoutMs: 10000, + requireOnline: true, + }, + opts.actor, + opts.screenName, + opts.color, + ); + const fileDocId = created.files[0]!.docId; + await client.flush(); + const gotIndex = await hub.hubHasDoc(created.indexDocId, 8000); + const gotFile = await hub.hubHasDoc(fileDocId, 8000); + if (!gotIndex || !gotFile) { + throw new Error('createSyncedHubProject: docs did not reach the hub'); + } + await client.disconnect(); + return { indexDocId: created.indexDocId, fileDocId, path, content }; +} + +/** Model going offline on a stable URL: drop live sockets + hold new upgrades. */ +export function goOffline(hub: TestHub): void { + hub.setHolding(true); + hub.dropConnections(); +} + +/** Model reconnect: accept queued and future upgrades again. */ +export function goOnline(hub: TestHub): void { + hub.setHolding(false); +} + +/** + * Reopen a cached hub project while `hub` is offline (holding upgrades): + * the peer wait times out fast and the client degrades to + * offline-from-cache. Returns the client plus the file listing. + */ +export async function reopenCachedOffline( + hub: TestHub, + project: CachedHubProject, + opts: { actor?: string; screenName?: string; color?: string } = {}, +): Promise> }> { + const rec = makeRecordingClient(); + const files = await rec.client.connect( + hub.url, + project.indexDocId, + opts.actor, + opts.screenName, + opts.color, + { storage: 'indexeddb', peerTimeoutMs: 300 }, + ); + return { ...rec, files }; +} + +/** + * Ground truth on the hub side: poll the hub's own repo until the file + * doc's text equals `expected` (or the deadline passes). This is what a + * collaborator on the hub would eventually see. + */ +export async function waitForHubFileText( + hub: TestHub, + fileDocId: string, + expected: string, + timeoutMs = 8000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const handle = await hub.repo.find(fileDocId as DocumentId); + const doc = handle.doc() as { text?: string } | undefined; + if (doc?.text === expected) return true; + } catch { + // unavailable — keep polling until the deadline + } + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} diff --git a/ts-packages/quarto-sync-client/src/test-hub.ts b/ts-packages/quarto-sync-client/src/test-hub.ts index 1dbceae20..95003d883 100644 --- a/ts-packages/quarto-sync-client/src/test-hub.ts +++ b/ts-packages/quarto-sync-client/src/test-hub.ts @@ -29,6 +29,22 @@ export interface TestHub { repo: Repo; /** Allow queued + future websocket upgrades to proceed. */ releaseUpgrades(): void; + /** + * Toggle whether new websocket upgrades are held (queued) or + * accepted. `setHolding(true)` after a client is already connected, + * combined with {@link dropConnections}, models a mid-session network + * loss on a *stable URL* — the offline window an offline-cached-hub + * test needs (bd-ysusqcb3). `setHolding(false)` accepts queued and + * future upgrades, modelling reconnect. `releaseUpgrades()` is the + * `setHolding(false)` alias kept for the D1 offline-creation tests. + */ + setHolding(hold: boolean): void; + /** + * Close all currently-open client sockets, simulating a dropped + * connection. The client's WebSocket adapter schedules a reconnect, + * which succeeds again once holding is off (see {@link setHolding}). + */ + dropConnections(): void; /** * True iff the hub holds the document (bounded wait). Uses the * repo's find with an overall deadline; "unavailable" or timeout @@ -95,12 +111,20 @@ export async function startTestHub(opts: TestHubOptions = {}): Promise throw new Error('test hub failed to bind a TCP port'); } + const setHolding = (hold: boolean): void => { + holding = hold; + if (!hold) { + for (const proceed of queued.splice(0)) proceed(); + } + }; + return { url: `ws://127.0.0.1:${address.port}/ws`, repo, - releaseUpgrades(): void { - holding = false; - for (const proceed of queued.splice(0)) proceed(); + releaseUpgrades: () => setHolding(false), + setHolding, + dropConnections(): void { + for (const ws of wss.clients) ws.terminate(); }, async hubHasDoc(docId: string, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; From 3b2fda50f713981cc09eafc29d617dc366f2baf1 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:21:32 +0100 Subject: [PATCH 10/16] hub-client: open cached hub project offline under local actor (B1, bd-qklxdkwh) A logged-off/offline open of a *cached* hub project now falls back to the persisted local actor and opens from cache instead of prompting sign-in; an uncached project prompts sign-in when online or reports offline-unopenable. The openActor seam derives online-vs-offline from whether resolveHubActor resolves null (logged off) or throws a TypeError (offline). Cache membership is a new stateless isDocCached() probe in quarto-sync-client. --- ...-hub-client-offline-cached-hub-projects.md | 28 +++- hub-client/src/App.tsx | 14 +- hub-client/src/services/openActor.test.ts | 144 ++++++++++++------ hub-client/src/services/openActor.ts | 89 ++++++++--- .../src/cached-hub-offline.test.ts | 31 ++-- ts-packages/quarto-sync-client/src/index.ts | 2 +- .../quarto-sync-client/src/storage-adapter.ts | 29 ++++ 7 files changed, 256 insertions(+), 81 deletions(-) diff --git a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md index 74da59056..16ee96228 100644 --- a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -122,13 +122,39 @@ returns rather than once. see the updated risk below. The remaining work is purely **app-layer** (actor resolution B1 + reconnect display-bridge B3) — the sync layer already opens offline read+write and syncs up. -- [ ] **B1 — Open cached hub project offline (supersede prompt-sign-in).** +- [x] **B1 — Open cached hub project offline (supersede prompt-sign-in).** `resolveActorForOpen` (and the `openActor` helper): for a hub project with no resolvable HMAC actor, fall back to the local actor and open from cache instead of firing `onNeedsSignIn`. Keep a genuine "never-cached + offline" case surfacing a clear "can't open — not cached, and you're offline" message (that one really can't open). Tests: cached hub doc opens offline read+write; never-cached hub doc offline reports the precise reason. + - **Done:** the `openActor` seam is extended and made online-vs-offline + aware for free — `resolveHubActor` *resolving* `null` means logged-off but + online (the request completed), while it *throwing* a `TypeError` means + offline (fetch failed); a non-network error (e.g. HTTP 500) still + propagates. Decision for a hub project with no HMAC actor: **cached → + open under the local actor** (no prompt); **uncached + online → + `onNeedsSignIn`** (signing in can fetch it); **uncached + offline → + `onCannotOpenOffline`** (a precise "not cached on this device, and you're + offline" message). Cache membership is a new stateless probe + `isDocCached(indexDocId)` in `quarto-sync-client` + (`storage-adapter.ts`) — `loadRange([bareId,'snapshot'/'incremental'])` + over the same storage the Repo writes, `interpretAsDocumentId` to + normalise the `automerge:` URL. Wired in `App.tsx` (`resolveActorForOpen` + deps gain `isCached: isDocCached` + `onCannotOpenOffline`); the three open + call sites keep the existing `=== null → return` abandon pattern. + - **Tests:** `openActor.test.ts` (8 cases — local, signed-in, auth-off, + logged-off-uncached→prompt, cached-logged-off→local, cached-offline→ + local, uncached-offline→cannot-open, 500→propagates); + `isDocCached` integration test over the cached-hub fixture; the B0 + baseline already proves the offline open under the local actor works + read+write. Strict hub-client build (`tsc -b && vite build`) green. + - **Verification honesty:** logic + probe are covered by unit/integration + tests + the strict build; the full browser E2E (sign out → open a cached + hub project offline → edit) is B5. Authorship still shows the local + actor across the offline window until reconnect — the display-bridge is + B3. - [ ] **B2 — Offline authorship under the local actor.** Author offline edits under the local actor; write `identities[localActor]`. Tests: offline edits attribute to this human (not an 8-hex stub) and persist across reload. diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index ba9bd12af..d63cf7af1 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -30,6 +30,7 @@ import { type EditorContentChange, } from '@quarto/preview-runtime'; import type { ProjectFile } from '@quarto/preview-runtime'; +import { isDocCached } from '@quarto/quarto-sync-client'; import * as projectStorage from './services/projectStorage'; import { installDebugApi } from './services/debugApi'; import { getUserIdentity, updateUserName, getOrCreateLocalActor } from './services/userSettings'; @@ -155,17 +156,26 @@ function App() { // Choose the authoring actor for a project when opening it. A local project // (no sync server) is authored under the stable per-browser local actor — // no server round-trip. A hub project uses the server-derived HMAC actor; - // if that needs a session we don't have (401 → null), prompt sign-in rather - // than letting the open fail silently (bd-gxz6tqbk / bd-u4p8xhdc). + // if that needs a session we don't have, the outcome depends on whether the + // project is cached and whether we're online (bd-gxz6tqbk / bd-u4p8xhdc / + // bd-qklxdkwh): a cached project opens from cache under the local actor; an + // uncached one prompts sign-in when online, or reports offline-unopenable. const resolveActorForOpen = useCallback( (indexDocId: string, syncServer: string): Promise => resolveActorForOpenImpl(indexDocId, syncServer, { getLocalActor: getOrCreateLocalActor, resolveHubActor: resolveActorId, + isCached: isDocCached, onNeedsSignIn: () => { setConnectionError('Sign in to open this hub project.'); setShowLogin(true); }, + onCannotOpenOffline: () => { + setConnectionError( + "This hub project isn't cached on this device, and you're offline. " + + 'Reconnect and sign in to open it.', + ); + }, }), [resolveActorId], ); diff --git a/hub-client/src/services/openActor.test.ts b/hub-client/src/services/openActor.test.ts index 64b75a65d..1b5650e9e 100644 --- a/hub-client/src/services/openActor.test.ts +++ b/hub-client/src/services/openActor.test.ts @@ -1,72 +1,124 @@ /** - * resolveActorForOpen (bd-u4p8xhdc follow-up). + * resolveActorForOpen. * - * Bug: with connection-gated auth, selecting a HUB project (one with a sync - * server) while logged off silently did nothing — the actor resolve returned - * null (401) and the open path just `return`ed with no feedback. A local - * project must still open with no session; a hub project that needs a session - * we don't have must PROMPT sign-in, not fail silently. + * v1 (bd-u4p8xhdc): with connection-gated auth, selecting a HUB project + * (one with a sync server) while logged off must not fail silently — a + * local project opens under the local actor; a hub project that needs a + * session prompts sign-in. * - * Plan: claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md + * B1 (bd-qklxdkwh, epic bd-xxjy9yfp): a logged-off/offline open of a + * *cached* hub project now opens from cache under the local actor instead + * of prompting. Only an *uncached* hub project needs a decision — prompt + * sign-in when online (signing in can fetch it) vs. report unopenable when + * offline. `resolveHubActor` resolving `null` means logged-off-but-online; + * it *throwing* a network error means offline. + * + * Plan: claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md */ import { describe, it, expect, vi } from 'vitest'; -import { resolveActorForOpen } from './openActor'; +import { resolveActorForOpen, type OpenActorDeps } from './openActor'; + +/** Deps with sensible defaults; override per case. */ +function makeDeps(overrides: Partial = {}): OpenActorDeps { + return { + getLocalActor: async () => 'localactor00000000000000000000aa', + resolveHubActor: async () => 'hubactor11111111111111111111bbbb', + isCached: async () => false, + onNeedsSignIn: vi.fn(), + onCannotOpenOffline: vi.fn(), + ...overrides, + }; +} describe('resolveActorForOpen', () => { - it('opens a local project (no sync server) under the local actor, no sign-in prompt', async () => { - const onNeedsSignIn = vi.fn(); - const actor = await resolveActorForOpen('automerge:local', '', { + it('opens a local project (no sync server) under the local actor, no prompts', async () => { + const deps = makeDeps({ + resolveHubActor: vi.fn(async () => null), // must not be consulted getLocalActor: async () => 'localactor00000000000000000000aa', - resolveHubActor: async () => null, // must not be consulted for a local project - onNeedsSignIn, }); + const actor = await resolveActorForOpen('automerge:local', '', deps); expect(actor).toBe('localactor00000000000000000000aa'); - expect(onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.resolveHubActor).not.toHaveBeenCalled(); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); }); it('opens a hub project with the resolved HMAC actor when signed in', async () => { - const onNeedsSignIn = vi.fn(); - const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { - getLocalActor: async () => 'localactor', - resolveHubActor: async () => 'hubactor11111111111111111111bbbb', - onNeedsSignIn, - }); + const deps = makeDeps({ resolveHubActor: async () => 'hubactor11111111111111111111bbbb' }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); expect(actor).toBe('hubactor11111111111111111111bbbb'); - expect(onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); }); - it('prompts sign-in when a hub project needs a session we do not have (401 → null)', async () => { - const onNeedsSignIn = vi.fn(); - const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { - getLocalActor: async () => 'localactor', - resolveHubActor: async () => null, // 401/403 from /auth/actor - onNeedsSignIn, + it('opens a hub project with no actor when auth is disabled (undefined, no prompt)', async () => { + const deps = makeDeps({ resolveHubActor: async () => undefined }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBeUndefined(); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); + }); + + it('prompts sign-in for an UNCACHED hub project when logged off but online (401 → null)', async () => { + const deps = makeDeps({ + resolveHubActor: async () => null, // 401/403, request completed → online + isCached: async () => false, }); - // Still returns null so the caller abandons the open... + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); expect(actor).toBeNull(); - // ...but surfaces the sign-in prompt instead of a silent no-op. - expect(onNeedsSignIn).toHaveBeenCalledTimes(1); + expect(deps.onNeedsSignIn).toHaveBeenCalledTimes(1); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); }); - it('opens a hub project with no actor when auth is disabled (undefined, no prompt)', async () => { - const onNeedsSignIn = vi.fn(); - const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', { - getLocalActor: async () => 'localactor', - resolveHubActor: async () => undefined, // AUTH_ENABLED false → insecure/no-auth hub - onNeedsSignIn, + it('B1: opens a CACHED hub project under the local actor when logged off (null), no prompt', async () => { + const deps = makeDeps({ + resolveHubActor: async () => null, + isCached: async () => true, + getLocalActor: async () => 'localactor00000000000000000000aa', }); - expect(actor).toBeUndefined(); - expect(onNeedsSignIn).not.toHaveBeenCalled(); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBe('localactor00000000000000000000aa'); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); }); - // B1 (bd-qklxdkwh, epic bd-xxjy9yfp) supersedes the prompt-sign-in - // behavior for *cached* hub projects: a logged-off open of a cached hub - // project falls back to the local actor and opens from cache instead of - // firing onNeedsSignIn; only a genuinely never-cached + offline project - // surfaces a precise "can't open" reason. These land red→green with B1's - // seam extension (cache-awareness in the openActor deps). - it.todo('B1: cached hub project + null HMAC actor opens under the local actor (no prompt)'); - it.todo('B1: never-cached hub project + offline reports a precise "not cached" reason'); + it('B1: opens a CACHED hub project under the local actor when offline (fetch throws)', async () => { + const deps = makeDeps({ + resolveHubActor: async () => { + throw new TypeError('Failed to fetch'); // offline + }, + isCached: async () => true, + getLocalActor: async () => 'localactor00000000000000000000aa', + }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBe('localactor00000000000000000000aa'); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); + }); + + it('B1: reports offline-unopenable for an UNCACHED hub project when offline (fetch throws)', async () => { + const deps = makeDeps({ + resolveHubActor: async () => { + throw new TypeError('Failed to fetch'); // offline + }, + isCached: async () => false, + }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBeNull(); + expect(deps.onCannotOpenOffline).toHaveBeenCalledTimes(1); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + }); + + it('propagates a genuine (non-network) resolve error instead of swallowing it', async () => { + const deps = makeDeps({ + resolveHubActor: async () => { + throw new Error('/auth/actor failed: 500'); // real server error + }, + }); + await expect(resolveActorForOpen('automerge:hub', 'wss://hub', deps)).rejects.toThrow('500'); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); + }); }); diff --git a/hub-client/src/services/openActor.ts b/hub-client/src/services/openActor.ts index b28094110..8997d33ce 100644 --- a/hub-client/src/services/openActor.ts +++ b/hub-client/src/services/openActor.ts @@ -1,27 +1,45 @@ /** - * Resolve the authoring actor to open a project with, and decide whether the - * open must be gated behind a hub sign-in. + * Resolve the authoring actor to open a project with, and decide whether + * the open must be gated behind a hub sign-in or reported as unopenable. * - * Connection-gated auth (bd-u4p8xhdc): a local project (no sync server) always - * opens, authored under the stable per-browser local actor. A hub project - * (has a sync server) uses the server-derived HMAC actor, which requires a - * valid session: + * Connection-gated auth (bd-u4p8xhdc): a local project (no sync server) + * always opens, authored under the stable per-browser local actor. A hub + * project (has a sync server) prefers the server-derived HMAC actor, which + * requires a valid session. `resolveHubActor` is three-valued and, by + * whether it *resolves* or *throws*, also tells us online-vs-offline: * - * - `string` → actor resolved; open with it. - * - `undefined` → auth is disabled (e.g. an insecure/no-auth hub); open with - * no explicit actor. No sign-in needed. - * - `null` → the hub rejected us (401/403) — we need a session we don't - * have. `onNeedsSignIn` fires so the caller can surface the - * sign-in prompt instead of a silent no-op, and `null` is - * returned so the caller still abandons this open attempt. + * - `string` → actor resolved (signed in); open with it. + * - `undefined` → auth disabled (insecure/no-auth hub); open with no + * explicit actor. No sign-in needed. + * - `null` → the hub answered 401/403 (logged off, but we ARE + * online — the request completed). + * - *throws* → the request itself failed (offline / hub unreachable). + * A network error is a `TypeError`; any other error + * (e.g. an HTTP 500) is a real failure and propagates. + * + * Offline-cached hub projects (bd-qklxdkwh, epic bd-xxjy9yfp): when there + * is no HMAC actor (logged off or offline) we no longer abandon. If the + * project is already cached locally we open it from cache under the local + * actor — a reconnect later switches back to the HMAC actor and bridges + * authorship (B3). Only a project that is *not* cached needs a decision: + * + * - online but logged off → `onNeedsSignIn` (signing in fetches it), + * - offline → `onCannotOpenOffline` (nothing can be done). + * + * In both uncached cases `null` is returned so the caller abandons the + * open; the callbacks surface the reason instead of a silent no-op. */ export interface OpenActorDeps { - /** Stable per-browser local actor for local-only projects. */ + /** Stable per-browser local actor, used for local-only and cached-offline opens. */ getLocalActor: () => Promise; - /** Server-derived HMAC actor for a hub project (three-valued; see above). */ + /** Server-derived HMAC actor for a hub project (three-valued; throws when offline). */ resolveHubActor: (indexDocId: string) => Promise; - /** Called when a hub open needs a session we don't have (prompt sign-in). */ + /** True iff the project's docs are already in the local cache. */ + isCached: (indexDocId: string) => Promise; + /** Hub project not cached, but we're online — signing in can fetch it. */ onNeedsSignIn: () => void; + /** Hub project not cached and we're offline — it genuinely can't be opened. */ + onCannotOpenOffline: () => void; } export async function resolveActorForOpen( @@ -33,11 +51,38 @@ export async function resolveActorForOpen( // Local-only project — always openable, no session required. return deps.getLocalActor(); } - const actor = await deps.resolveHubActor(indexDocId); - if (actor === null) { - // Hub project needs a session we don't have. Surface the sign-in prompt - // rather than letting the caller's `=== null` guard fail silently. - deps.onNeedsSignIn(); + + let hubActor: string | null | undefined; + let offline = false; + try { + hubActor = await deps.resolveHubActor(indexDocId); + } catch (err) { + // A network failure (offline / hub unreachable) surfaces as a + // TypeError from fetch. Anything else (HTTP 500, etc.) is a genuine + // error the caller should see — rethrow it. + if (err instanceof TypeError) { + offline = true; + hubActor = null; + } else { + throw err; + } } - return actor; + + if (hubActor === null) { + // No usable session (logged off) or offline. Open from cache under the + // local actor if we have a cached copy; the hub connection degrades to + // offline-from-cache and a later reconnect (B3) restores the HMAC actor. + if (await deps.isCached(indexDocId)) { + return deps.getLocalActor(); + } + // Not cached: signing in would fetch it (online), but offline we can't. + if (offline) { + deps.onCannotOpenOffline(); + } else { + deps.onNeedsSignIn(); + } + return null; + } + + return hubActor; // string (signed in) or undefined (auth disabled) } diff --git a/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts index d5d7ef999..c80bfb19f 100644 --- a/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts +++ b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts @@ -19,6 +19,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import 'fake-indexeddb/auto'; import { IDBFactory } from 'fake-indexeddb'; +import { isDocCached } from './storage-adapter.js'; import { startTestHub, type TestHub } from './test-hub.js'; import { createSyncedHubProject, @@ -98,16 +99,28 @@ describe('offline-cached hub project (sync-client baseline)', () => { await reopened.client.disconnect(); }, 30000); - // ── Behavioral specs the later phases implement (red→green with impl) ── + it('isDocCached distinguishes a synced project from a never-seen doc', async () => { + // The probe the openActor seam uses to decide "open from cache" vs + // "prompt sign-in / report offline-unopenable" (B1, bd-qklxdkwh). + const project = await createSyncedHubProject(hub, { + path: 'notes.qmd', + content: 'online body\n', + actor: HUB_ACTOR, + screenName: 'Alice', + color: '#3366cc', + }); - // B1 (bd-qklxdkwh) — the app-level open path currently prompts sign-in - // for a logged-off hub open (openActor → onNeedsSignIn). B1 makes a - // *cached* hub project open under the local actor instead; only a - // genuinely never-cached + offline project reports a precise - // "can't open" reason. (Unit-tested at the openActor seam; see - // openActor.test.ts.) - it.todo('B1: cached hub project opens offline under the local actor (no sign-in prompt)'); - it.todo('B1: never-cached hub project offline reports a precise "not cached" reason'); + expect(await isDocCached(project.indexDocId)).toBe(true); + // A well-formed but never-seen doc id is not cached. + expect(await isDocCached('automerge:2j9knpCsexT8gPeMFXQCLbTQGKC')).toBe(false); + // A malformed id is not cached (and does not throw). + expect(await isDocCached('not-an-automerge-url')).toBe(false); + }, 30000); + + // ── Behavioral specs the later phases implement (red→green with impl) ── + // B1 (bd-qklxdkwh) is DONE: the open decision is unit-tested at the + // openActor seam (openActor.test.ts) and the offline open under the + // local actor is exercised by the baseline test above. // B2 (bd-ab44wv07) — offline edits author under the local actor and // write identities[localActor] so they display as this human, not an diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index a8066d86f..5d04100eb 100644 --- a/ts-packages/quarto-sync-client/src/index.ts +++ b/ts-packages/quarto-sync-client/src/index.ts @@ -87,7 +87,7 @@ export type { // storageId in their handshake metadata (the real samod hub always // announces one — exit-drain delivery confirmation keys off it, // bd-10deu8h4). -export { MemoryStorageAdapter } from './storage-adapter.js'; +export { MemoryStorageAdapter, isDocCached } from './storage-adapter.js'; // Export utilities export { computeSHA256 } from './hash.js'; diff --git a/ts-packages/quarto-sync-client/src/storage-adapter.ts b/ts-packages/quarto-sync-client/src/storage-adapter.ts index f8f6d7072..e9f2fefcd 100644 --- a/ts-packages/quarto-sync-client/src/storage-adapter.ts +++ b/ts-packages/quarto-sync-client/src/storage-adapter.ts @@ -15,6 +15,8 @@ import { StorageAdapter, + interpretAsDocumentId, + type AnyDocumentId, type Chunk, type StorageAdapterInterface, type StorageKey, @@ -69,6 +71,33 @@ export class MemoryStorageAdapter extends StorageAdapter implements StorageAdapt } } +/** + * True iff the document already has chunks in the local cache — i.e. the + * project was synced at least once and can be opened offline. Reads the + * same storage the Repo writes (default IndexedDB in the browser) via a + * transient, read-only adapter; no network. automerge-repo stores a doc's + * chunks under `[documentId, "snapshot"|"incremental", …]` keyed by the + * *bare* document id, so we normalise the `automerge:` URL first. + * + * The app's open path uses this to decide whether a logged-off/offline + * hub project can open from cache under the local actor (bd-qklxdkwh) or + * must prompt sign-in / report that it is offline and uncached. + */ +export async function isDocCached(indexDocId: string, kind?: StorageKind): Promise { + let documentId: string; + try { + documentId = interpretAsDocumentId(indexDocId as unknown as AnyDocumentId); + } catch { + // A malformed id can't correspond to anything cached. + return false; + } + const adapter = buildStorageAdapter(kind); + const snapshot = await adapter.loadRange([documentId, 'snapshot']); + if (snapshot.length > 0) return true; + const incremental = await adapter.loadRange([documentId, 'incremental']); + return incremental.length > 0; +} + export function buildStorageAdapter(kind?: StorageKind): StorageAdapterInterface { // Explicit memory selection (q2-preview SPA): ephemeral docs make // IndexedDB useless as a cache, and its open would otherwise gate From dcc609b1b33135b83c08cd84cfe689bfa7c2aa24 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:46:21 +0100 Subject: [PATCH 11/16] hub-client: offline authorship + reconnect actor-switch for cached hub projects (B2-B5, bd-xxjy9yfp) Offline edits to a cached hub project author under the local actor with an identities row (B2, existing connect path). On reconnect, SyncClient.switchActor re-authors open handles to the server-trusted HMAC actor and bridges the identities so the offline+online timeline reads as one human; the App wires this via reconcileHubActorOnReconnect on onConnectionChange (B3). Offline edits to N existing cached docs all sync on reconnect via normal automerge sync, no D1 fix (B4). Docs + honest E2E status in LOCAL-FIRST.md and the plan (B5). --- ...-hub-client-offline-cached-hub-projects.md | 56 +++++- hub-client/LOCAL-FIRST.md | 18 ++ hub-client/src/App.tsx | 44 ++++- .../preview-runtime/src/automergeSync.ts | 10 ++ .../src/cached-hub-offline.test.ts | 163 ++++++++++++++++-- .../quarto-sync-client/src/cached-hub.ts | 58 +++++-- ts-packages/quarto-sync-client/src/client.ts | 33 ++++ .../quarto-sync-client/src/test-hub.ts | 14 ++ 8 files changed, 363 insertions(+), 33 deletions(-) diff --git a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md index 16ee96228..5b7566598 100644 --- a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -155,15 +155,37 @@ returns rather than once. hub project offline → edit) is B5. Authorship still shows the local actor across the offline window until reconnect — the display-bridge is B3. -- [ ] **B2 — Offline authorship under the local actor.** Author offline edits +- [x] **B2 — Offline authorship under the local actor.** Author offline edits under the local actor; write `identities[localActor]`. Tests: offline edits attribute to this human (not an 8-hex stub) and persist across reload. -- [ ] **B3 — Reconnect switch + display bridge + sync-up.** On sign-in/online + - **Done (verification-only — no production change needed):** the existing + `connect` path already writes `identities[actorId]` (via `setIdentity` + when actor+screenName are given) and authors edits under `state.actorId` + (via `applyActorId` on every `findDoc`). B1 passes the local actor + + screenName on an offline open, so offline edits already attribute to the + human and persist. Locked in by a `cached-hub-offline.test.ts` case: + offline edit → latest file-doc change carries the local actor, + `identities[localActor] = {name}`, and both survive a reload (fresh + offline client, no screenName re-write). +- [x] **B3 — Reconnect switch + display bridge + sync-up.** On sign-in/online for the project: fetch `stableActor`, `applyActorId`, bridge `identities` (steps 4–7), (re)attach/reactivate the network adapter, flush offline edits. Reuse the adoption A5 mechanics. Tests: future edits carry `stableActor`; offline + online history display as one human; offline edits reach the (fake) hub after reconnect. + - **Done:** new `SyncClient.switchActor(actorId, screenName?, color?)` + (`client.ts`, exposed through the `preview-runtime` facade) re-authors all + open handles via `applyActorId` and writes the bridge identity row so the + HMAC actor inherits the human's name/color. The App calls it from + `onConnectionChange(true)` for a hub project via + `reconcileHubActorOnReconnect`: it resolves the HMAC actor (a peer only + reconnects with a valid cookie, so it resolves) and switches only if the + current actor differs — a no-op for a normally-online project or a + transient blip. Tests (`cached-hub-offline.test.ts` B3): after switch + `getActorId()===HMAC`; the post-switch file-doc change carries the HMAC + actor while the offline change still carries the local actor (history + immutable); `identities` has both actors as one human; the offline + online + edits reach the in-process hub. - [ ] **B4 — Durability of offline edits (D1 coordination).** Ensure offline edits to *existing cached* docs reliably sync on reconnect. Determine whether this shares D1's announce-on-connect fix (`bd-10bdjmjb`) or is already covered @@ -174,12 +196,40 @@ returns rather than once. with no D1 fix. B4 shrinks to widening this to *N* docs (and a newly-created-offline doc under an existing project, which *is* the D1 case) and confirming it holds under the real hub-client reconnect wiring. -- [ ] **B5 — End-to-end verification + docs.** Real browser: open a hub project + - **Done:** `cached-hub-offline.test.ts` B4 edits N=4 existing cached docs + offline, reconnects, and asserts **all N** updated docs reach the in-process + hub via normal automerge sync — **no D1 announce-on-connect fix required** + for existing docs. The one case that *does* remain D1 territory + (`bd-10bdjmjb`) — creating a **new** doc offline under an existing project — + is deliberately out of this test's scope and stays gated on D1. +- [x] **B5 — End-to-end verification + docs.** Real browser: open a hub project online, go offline (or sign out), edit, reconnect/sign-in, confirm the edits reach the hub and authorship reads as one human in the UI/DevTools. Record exact steps + observed output per the repo end-to-end policy. Update `hub-client/LOCAL-FIRST.md` (the table currently says sync/collab are hub-only; add the offline-cached-hub-project row). + - **Docs:** `hub-client/LOCAL-FIRST.md` gains a "Working offline with a hub + project" section + a table row ("Open & edit a *previously-opened* hub + project offline — ✅ from cache"). + - **Automated E2E (library level, real hub):** `cached-hub-offline.test.ts` + drives the **real sync client** against a real in-process + (`startTestHub`) hub through the entire timeline — create online → go + offline (drop sockets + hold upgrades on a stable URL) → reopen from cache + → offline edit → reconnect → `switchActor` → sync-up — over + `fake-indexeddb`. This exercises the actual `connect`/`applyActorId`/ + `switchActor`/storage code paths end to end, not mocks. + - **Automated (decision + build):** `openActor.test.ts` (8 cases) covers the + online-vs-offline / cached-vs-uncached open decision; the strict hub-client + build (`tsc -b && vite build`) confirms the App wiring compiles. + - **Manual (honest gap, per the A7v1 precedent):** the full *browser* session + with a **live OIDC provider** — sign in, open a hub project, sign out / go + offline in the tab, edit, sign back in, watch the edits sync and authorship + collapse to one human in the UI — is **not automated** (the headless + harness has no OIDC client id, and under `--allow-insecure-auth` + `AUTH_ENABLED` is false so the null/offline actor branches aren't hit). + Manual recipe: `npm run local-prod`, create a hub project, use DevTools → + Network → Offline, reload (opens from cache, editable), edit, go back + online, confirm the edit reaches the hub and authorship shows one author. ## Interaction with the shipped v1 diff --git a/hub-client/LOCAL-FIRST.md b/hub-client/LOCAL-FIRST.md index a94845730..b52fbe656 100644 --- a/hub-client/LOCAL-FIRST.md +++ b/hub-client/LOCAL-FIRST.md @@ -39,12 +39,30 @@ Signing out (or a session expiring) never disturbs a local project — only hub-backed projects need a valid session, and losing one simply returns you to the project list. +## Working offline with a hub project + +A hub project you've opened before is **cached in this browser**, so you can +keep working on it even with no connection — for example on a plane, or after +signing out: + +- Opening a cached hub project while offline (or logged out) loads it from the + local cache and is **fully editable**. You don't need to sign in first. +- Your offline edits are attributed to the local author ("You") while you're + disconnected. +- When you reconnect (come back online / sign in), your offline edits **sync up + to the hub automatically**, and the whole editing history — offline and + online — shows as one author. + +A hub project you've **never** opened on this device can't be opened offline +(there's nothing cached yet); the app tells you to reconnect and sign in. + ## What requires a hub | Action | Local (no account) | Connected to a hub | | --- | --- | --- | | Create / edit / preview a project | ✅ | ✅ | | Persist across reloads (same browser) | ✅ | ✅ | +| Open & edit a *previously-opened* hub project offline | — | ✅ (from cache) | | Sync across your own devices | — | ✅ | | Real-time collaboration with others | — | ✅ | | Share a project link | — | ✅ | diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index d63cf7af1..098384cf5 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -25,6 +25,8 @@ import { getFileContent, applyEditorOperations, createNewProject, + getActorId, + switchActor, type ActorIdentity, type CaptureRef, type EditorContentChange, @@ -180,6 +182,27 @@ function App() { [resolveActorId], ); + // On reconnect for a hub project we opened offline under the local actor, + // switch to the server-trusted HMAC actor and bridge authorship display so + // the offline window and online edits read as one human (bd-g5apu5bm). A + // peer only reconnects with a valid cookie, so the HMAC actor resolves; a + // normally-online project already carries it, making this a no-op. + const reconcileHubActorOnReconnect = useCallback( + async (indexDocId: string): Promise => { + const current = getActorId(); + let stable: string | null | undefined; + try { + stable = await resolveActorId(indexDocId); + } catch { + return; // still offline / transient — nothing to switch to yet + } + if (typeof stable === 'string' && stable !== current) { + switchActor(stable, screenNameRef.current, cursorColorRef.current); + } + }, + [resolveActorId], + ); + // Capture auth error from redirect query param (once, before URL is cleaned). const [authError] = useState(() => { const has = new URLSearchParams(window.location.search).has('auth_error'); @@ -228,6 +251,12 @@ function App() { routeRef.current = route; const navigateToFileRef = useRef(navigateToFile); navigateToFileRef.current = navigateToFile; + // Current identity, read from the reconnect handler (which is registered + // once per project and would otherwise capture stale name/color). + const screenNameRef = useRef(screenName); + screenNameRef.current = screenName; + const cursorColorRef = useRef(cursorColor); + cursorColorRef.current = cursorColor; useEffect(() => { const enabled = @@ -508,16 +537,23 @@ function App() { if (!connected && project) { // Connection lost - show error setConnectionError('Connection lost - working offline'); - } else if (connected && connectionError === 'Connection lost - working offline') { - // Connection restored - clear error - setConnectionError(null); + } else if (connected) { + if (connectionError === 'Connection lost - working offline') { + // Connection restored - clear error + setConnectionError(null); + } + // Reconnected: if this hub project was opened offline under the + // local actor, switch to the HMAC actor and bridge authorship. + if (project?.syncServer) { + void reconcileHubActorOnReconnect(project.indexDocId); + } } }, onError: (error) => { setConnectionError(error.message); }, }); - }, [project]); + }, [project, reconcileHubActorOnReconnect]); const handleSelectProject = useCallback(async (selectedProject: ProjectEntry, filePathOverride?: string) => { setIsConnecting(true); diff --git a/ts-packages/preview-runtime/src/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts index 5b2dd9494..c1070c336 100644 --- a/ts-packages/preview-runtime/src/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -300,6 +300,16 @@ export function getActorId(): string | null { return client?.getActorId() ?? null; } +/** + * Switch the authoring actor for the open project and bridge authorship + * display. See `SyncClient.switchActor` — used on reconnect to move a + * hub project opened offline (local actor) back to the server-trusted + * HMAC actor. No-op when disconnected. + */ +export function switchActor(actorId: string, screenName?: string, color?: string): void { + client?.switchActor(actorId, screenName, color); +} + /** * Flush pending storage writes to the local cache. See * `SyncClient.flush` — matters for local-first projects, which have no diff --git a/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts index c80bfb19f..69dc7cb86 100644 --- a/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts +++ b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import 'fake-indexeddb/auto'; import { IDBFactory } from 'fake-indexeddb'; +import { getAllChanges, decodeChange, type Doc } from '@automerge/automerge'; import { isDocCached } from './storage-adapter.js'; import { startTestHub, type TestHub } from './test-hub.js'; @@ -26,7 +27,9 @@ import { goOffline, goOnline, reopenCachedOffline, + readIdentities, waitForHubFileText, + waitForOnline, } from './cached-hub.js'; // A stable per-browser local actor stand-in (32 hex = a valid Automerge @@ -117,19 +120,149 @@ describe('offline-cached hub project (sync-client baseline)', () => { expect(await isDocCached('not-an-automerge-url')).toBe(false); }, 30000); - // ── Behavioral specs the later phases implement (red→green with impl) ── - // B1 (bd-qklxdkwh) is DONE: the open decision is unit-tested at the - // openActor seam (openActor.test.ts) and the offline open under the - // local actor is exercised by the baseline test above. - - // B2 (bd-ab44wv07) — offline edits author under the local actor and - // write identities[localActor] so they display as this human, not an - // 8-hex stub, and persist across reload. - it.todo('B2: offline edits author under the local actor with an identities row'); - - // B3 (bd-g5apu5bm) — on reconnect: fetch the HMAC stableActor, - // applyActorId to all handles, bridge identities[stableActor] = - // identities[localActor], so the offline window and online edits - // display as one human. - it.todo('B3: reconnect switches to the HMAC actor and bridges identities to one human'); + it('B2: offline edits author under the local actor with an identities row', async () => { + // One human ("Charlie") editing their hub project offline. The edit + // must attribute to the local actor with an identities row (so it shows + // as Charlie, not an 8-hex stub) and persist across a reload. + const project = await createSyncedHubProject(hub, { + path: 'notes.qmd', + content: 'online body\n', + actor: HUB_ACTOR, + screenName: 'Charlie', + color: '#3366cc', + }); + + goOffline(hub); + const c1 = await reopenCachedOffline(hub, project, { + actor: LOCAL_ACTOR, + screenName: 'Charlie', + color: '#cc6633', + }); + c1.client.updateFileContent('notes.qmd', 'offline edit by charlie\n'); + await c1.client.flush(); + + // Authorship: the client authors under the local actor, and the latest + // change on the file doc carries it. + expect(c1.client.getActorId()).toBe(LOCAL_ACTOR); + const fileDoc = c1.client.getFileHandle('notes.qmd')?.doc() as Doc; + const latest = getAllChanges(fileDoc).map((ch) => decodeChange(ch)); + expect(latest[latest.length - 1]!.actor).toBe(LOCAL_ACTOR); + + // Display: identities has a row for the local actor with the human name. + expect(readIdentities(c1.client)[LOCAL_ACTOR]).toMatchObject({ name: 'Charlie' }); + + // Disconnect the first session cleanly (one client at a time): connect + // the held socket, wait for the peer, then close it. + goOnline(hub); + await waitForOnline(c1); + await c1.client.disconnect(); + + // Reload (a fresh offline client, NO screenName so nothing re-writes the + // row): both the edit and the identity must come back from the cache. + goOffline(hub); + const c2 = await reopenCachedOffline(hub, project, { actor: LOCAL_ACTOR }); + expect(c2.client.getFileContent('notes.qmd')).toBe('offline edit by charlie\n'); + expect(readIdentities(c2.client)[LOCAL_ACTOR]).toMatchObject({ name: 'Charlie' }); + + goOnline(hub); + await waitForOnline(c2); + await c2.client.disconnect(); + }, 30000); + + it('B3: reconnect switches to the HMAC actor and bridges authorship to one human', async () => { + // One human ("Charlie"): create online (HMAC actor), edit offline (local + // actor), reconnect → switchActor back to the HMAC actor and bridge the + // identity, then edit again. + const project = await createSyncedHubProject(hub, { + path: 'notes.qmd', + content: 'online body\n', + actor: HUB_ACTOR, + screenName: 'Charlie', + color: '#3366cc', + }); + + goOffline(hub); + const c = await reopenCachedOffline(hub, project, { + actor: LOCAL_ACTOR, + screenName: 'Charlie', + color: '#3366cc', + }); + c.client.updateFileContent('notes.qmd', 'edited offline by charlie\n'); + await c.client.flush(); + expect(c.client.getActorId()).toBe(LOCAL_ACTOR); + + // Reconnect: peer comes back, then the app resolves the HMAC actor and + // switches to it (the App wires this on onConnectionChange). + goOnline(hub); + await waitForOnline(c); + c.client.switchActor(HUB_ACTOR, 'Charlie', '#3366cc'); + expect(c.client.getActorId()).toBe(HUB_ACTOR); + + // Future edits carry the HMAC actor; the offline change still carries the + // local actor (history is immutable, never rewritten). + c.client.updateFileContent('notes.qmd', 'edited online after reconnect\n'); + await c.client.flush(); + const changes = getAllChanges( + c.client.getFileHandle('notes.qmd')?.doc() as Doc, + ).map((ch) => decodeChange(ch)); + const actors = new Set(changes.map((ch) => ch.actor)); + expect(changes[changes.length - 1]!.actor).toBe(HUB_ACTOR); + expect(actors.has(LOCAL_ACTOR)).toBe(true); + expect(actors.has(HUB_ACTOR)).toBe(true); + + // Display bridge: both actors resolve to the same human, so the whole + // timeline reads as one person. + const ids = readIdentities(c.client); + expect(ids[LOCAL_ACTOR]).toMatchObject({ name: 'Charlie' }); + expect(ids[HUB_ACTOR]).toMatchObject({ name: 'Charlie' }); + + // Sync-up: the offline edit and the post-reconnect edit both reach the hub. + expect( + await waitForHubFileText(hub, project.fileDocId, 'edited online after reconnect\n', 15000), + 'edits must reach the hub after reconnect', + ).toBe(true); + + await c.client.disconnect(); + }, 30000); + + it('B4: N offline edits to existing cached docs all reach the hub on reconnect', async () => { + // Widens the baseline finding to N docs: editing several existing cached + // docs offline and reconnecting flushes ALL of them via normal automerge + // sync — no D1 announce-on-connect fix needed (D1, bd-10bdjmjb, is about + // *newly created* offline docs, which this test deliberately does not do). + const N = 4; + const project = await createSyncedHubProject(hub, { + files: Array.from({ length: N }, (_, i) => ({ + path: `file-${i}.qmd`, + content: `online ${i}\n`, + })), + actor: HUB_ACTOR, + screenName: 'Charlie', + color: '#3366cc', + }); + + goOffline(hub); + const c = await reopenCachedOffline(hub, project, { + actor: LOCAL_ACTOR, + screenName: 'Charlie', + }); + // Edit every file offline. + for (let i = 0; i < N; i++) { + c.client.updateFileContent(`file-${i}.qmd`, `offline edit ${i}\n`); + } + await c.client.flush(); + + goOnline(hub); + // All N updated docs must reach the hub. + for (const f of project.files) { + const i = project.files.indexOf(f); + expect( + await waitForHubFileText(hub, f.fileDocId, `offline edit ${i}\n`, 15000), + `${f.path} must reach the hub after reconnect`, + ).toBe(true); + } + + await waitForOnline(c); + await c.client.disconnect(); + }, 40000); }); diff --git a/ts-packages/quarto-sync-client/src/cached-hub.ts b/ts-packages/quarto-sync-client/src/cached-hub.ts index 9c0ea6404..a0bb25fb2 100644 --- a/ts-packages/quarto-sync-client/src/cached-hub.ts +++ b/ts-packages/quarto-sync-client/src/cached-hub.ts @@ -59,34 +59,40 @@ export function readIdentities( export interface CachedHubProject { indexDocId: string; + /** The first file (convenience for single-file tests). */ fileDocId: string; path: string; content: string; + /** Every file created, in order. */ + files: Array<{ path: string; content: string; fileDocId: string }>; } /** * Create a project online against `hub` and wait until the hub actually - * holds both docs — the project is then cached on both ends. The creator - * client is disconnected before returning; callers reopen with a fresh - * client to model a reload. + * holds the index and every file doc — the project is then cached on both + * ends. The creator client is disconnected before returning; callers reopen + * with a fresh client to model a reload. Pass `files` for a multi-file + * project, or `path`/`content` for a single file. */ export async function createSyncedHubProject( hub: TestHub, opts: { path?: string; content?: string; + files?: Array<{ path: string; content: string }>; actor?: string; screenName?: string; color?: string; } = {}, ): Promise { - const path = opts.path ?? 'doc.qmd'; - const content = opts.content ?? 'online body\n'; + const inputs = opts.files ?? [ + { path: opts.path ?? 'doc.qmd', content: opts.content ?? 'online body\n' }, + ]; const { client } = makeRecordingClient(); const created = await client.createNewProject( { syncServer: hub.url, - files: [{ path, content, contentType: 'text' }], + files: inputs.map((f) => ({ ...f, contentType: 'text' as const })), storage: 'indexeddb', // Create while the peer is connected so the docs flush to the hub // immediately (no background-sync race). @@ -97,15 +103,27 @@ export async function createSyncedHubProject( opts.screenName, opts.color, ); - const fileDocId = created.files[0]!.docId; await client.flush(); + const files = inputs.map((f, i) => ({ + path: f.path, + content: f.content, + fileDocId: created.files[i]!.docId, + })); const gotIndex = await hub.hubHasDoc(created.indexDocId, 8000); - const gotFile = await hub.hubHasDoc(fileDocId, 8000); - if (!gotIndex || !gotFile) { - throw new Error('createSyncedHubProject: docs did not reach the hub'); + if (!gotIndex) throw new Error('createSyncedHubProject: index did not reach the hub'); + for (const f of files) { + if (!(await hub.hubHasDoc(f.fileDocId, 8000))) { + throw new Error(`createSyncedHubProject: ${f.path} did not reach the hub`); + } } await client.disconnect(); - return { indexDocId: created.indexDocId, fileDocId, path, content }; + return { + indexDocId: created.indexDocId, + fileDocId: files[0]!.fileDocId, + path: files[0]!.path, + content: files[0]!.content, + files, + }; } /** Model going offline on a stable URL: drop live sockets + hold new upgrades. */ @@ -119,6 +137,24 @@ export function goOnline(hub: TestHub): void { hub.setHolding(false); } +/** + * Wait until the client has a connected peer. Disconnecting while the + * socket is still mid-handshake throws in `ws` ("closed before the + * connection was established"); waiting for the peer first makes teardown + * clean. Call after {@link goOnline} before `disconnect()`. + */ +export async function waitForOnline( + rec: { client: SyncClient }, + timeoutMs = 10000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (rec.client.getSyncDiagnostics().connectedPeers > 0) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error('waitForOnline: no peer connected within timeout'); +} + /** * Reopen a cached hub project while `hub` is offline (holding upgrades): * the peer wait times out fast and the client degrades to diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 313c1af07..d458e3ab1 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -1731,6 +1731,38 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return state.actorId; } + /** + * Switch the authoring actor for all open handles and bridge authorship + * display (bd-g5apu5bm, epic bd-xxjy9yfp). + * + * Used on reconnect: a hub project opened offline authors under the + * per-browser local actor; when the session returns we switch to the + * server-trusted HMAC actor so *future* edits carry it, and copy the + * human's identity onto the new actor so the offline-window history and + * the online edits display as one person. History is never rewritten + * (automerge changes are immutable) — only future authorship and the + * identities map change. No-op if disconnected or already on `actorId`. + */ + function switchActor(actorId: string, screenName?: string, color?: string): void { + if (!state.repo || !actorId || actorId === state.actorId) return; + state.actorId = actorId; + // Re-author future changes on every open handle. + if (state.indexHandle) applyActorId(state.indexHandle, actorId); + for (const handle of state.fileHandles.values()) { + applyActorId(handle, actorId); + } + // Bridge authorship display: the new actor inherits the human's name and + // color, so the whole timeline reads as one person. Written into the + // index doc, so it syncs up like any other change. + if (state.indexHandle && screenName) { + state.indexHandle.change((d) => { + setIdentity(d, actorId, screenName, color || ''); + }); + const doc = state.indexHandle.doc(); + if (doc) notifyIdentitiesIfChanged(doc); + } + } + /** * Flush all pending storage writes to the local cache. * @@ -1768,6 +1800,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS getSyncDiagnostics, createNewProject, getActorId, + switchActor, flush, }; } diff --git a/ts-packages/quarto-sync-client/src/test-hub.ts b/ts-packages/quarto-sync-client/src/test-hub.ts index 95003d883..f0c87b827 100644 --- a/ts-packages/quarto-sync-client/src/test-hub.ts +++ b/ts-packages/quarto-sync-client/src/test-hub.ts @@ -73,13 +73,21 @@ export async function startTestHub(opts: TestHubOptions = {}): Promise } }); + // Sockets of upgrade requests that arrived while holding and haven't + // completed the handshake yet. Tracked so stop() can destroy them — + // otherwise httpServer.close() hangs waiting on these half-open sockets. + const pendingSockets = new Set<{ destroy(): void; destroyed: boolean }>(); + const wss = new WebSocketServer({ noServer: true }); httpServer.on('upgrade', (req, socket, head) => { if (req.url !== '/ws') { socket.destroy(); return; } + pendingSockets.add(socket); + socket.on('close', () => pendingSockets.delete(socket)); const proceed = (): void => { + pendingSockets.delete(socket); // The socket may have died while queued. if (!socket.destroyed) { wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); @@ -140,6 +148,12 @@ export async function startTestHub(opts: TestHubOptions = {}): Promise return false; }, async stop(): Promise { + // Destroy any held (never-upgraded) sockets and terminate live ones — + // httpServer.close() waits on open connections, so a lingering + // half-open upgrade (the offline window) would hang teardown. + for (const s of pendingSockets) if (!s.destroyed) s.destroy(); + pendingSockets.clear(); + for (const ws of wss.clients) ws.terminate(); // shutdown() flushes storage, and flush() throws "DocHandle is // not ready" for docs that were announced but never delivered — // exactly the half-state the exit-drain tests (bd-10deu8h4) From ba9df1810a6cc0e35c5ae24b9790fb713174f698 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:59:45 +0100 Subject: [PATCH 12/16] scripts: make local-prod's static port configurable via PORT env var STATIC_PORT (and the baked-in VITE_DEFAULT_SYNC_SERVER for build:local-prod) now honor a PORT override, defaulting to 8080, so local-prod can run on a port other than the default (e.g. PORT=5173). --- hub-client/package.json | 2 +- scripts/local-prod.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hub-client/package.json b/hub-client/package.json index 1c8acc4b2..a38214bad 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -11,7 +11,7 @@ "build:wasm": "node scripts/build-wasm.js", "build:sandboxed": "cd quarto-hub-sandboxed-preview && npm install --silent && npm run build", "build:all": "npm run build:wasm && npm run build:sandboxed && npm run build", - "build:local-prod": "npm run build:wasm && npm run build:sandboxed && VITE_DEFAULT_SYNC_SERVER=ws://127.0.0.1:8080/ws VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html vite build", + "build:local-prod": "npm run build:wasm && npm run build:sandboxed && VITE_DEFAULT_SYNC_SERVER=ws://127.0.0.1:${PORT:-8080}/ws VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html vite build", "postinstall": "cd quarto-hub-sandboxed-preview && npm install --silent", "typecheck": "tsc -p tsconfig.app.json --noEmit", "lint": "eslint .", diff --git a/scripts/local-prod.sh b/scripts/local-prod.sh index ff26416c9..171aff14f 100755 --- a/scripts/local-prod.sh +++ b/scripts/local-prod.sh @@ -9,7 +9,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" HUB_CLIENT_DIR="$PROJECT_ROOT/hub-client" DATA_DIR="$PROJECT_ROOT/.local-prod-data" HUB_PORT=3001 -STATIC_PORT=8080 +STATIC_PORT="${PORT:-8080}" Q2_SANDBOXED_PREVIEW_PORT=8081 # Color output From d98ac02af2bc4fa8dc6503c50ed5d299591193e4 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:59:58 +0100 Subject: [PATCH 13/16] hub-client: restore editable Sync Server URL field in Create/Import (bd-ivkf752c) A4 (bd-u4p8xhdc) removed this field, defaulting silently to the active project set's server. Restored per user request: the field is back in both forms, defaulting to DEFAULT_SYNC_SERVER and editable; clearing it still creates a local-only project. --- ...hub-client-connection-gated-local-first.md | 11 ++ hub-client/e2e/local-first.spec.ts | 15 ++- .../ProjectSelector.connect.test.tsx | 10 +- .../ProjectSelector.create.test.tsx | 105 ++++++++++++++++++ .../ProjectSelector.import.test.tsx | 24 +++- hub-client/src/components/ProjectSelector.tsx | 30 ++++- 6 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 hub-client/src/components/ProjectSelector.create.test.tsx diff --git a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md index ed0205ab7..75c6f3097 100644 --- a/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -125,6 +125,17 @@ follow-on**: `2026-07-06-hub-client-local-project-adoption.md`. cached hub project while logged off is intentionally NOT done here — that is offline-durability / D1 territory.)* +- **Sync Server URL field restored to Create/Import** (user request, + bd-ivkf752c, discovered-from bd-u4p8xhdc). A4 removed the field entirely, + defaulting silently to `projectSetSyncServer ?? ''`. Reverted: the field is + back in both forms, defaulting to `DEFAULT_SYNC_SERVER` (same as the + Connect form / pre-A4 behavior) and editable; clearing it still creates a + local-only project (`isLocal = !syncServer` in `App.tsx` is unchanged). + Tests: `ProjectSelector.create.test.tsx` (new), `ProjectSelector.import.test.tsx`, + `ProjectSelector.connect.test.tsx` (updated); `e2e/local-first.spec.ts` + updated to clear the field explicitly so the offline-creation test doesn't + target the real `wss://sync.automerge.org` from `.env`. + ## Non-goals - **v1 does not publish an existing local project up to a hub** — that is the adoption follow-on plan (`2026-07-06-hub-client-local-project-adoption.md`), deferred behind D1 (`bd-10bdjmjb`). - Does **not** change server-side auth enforcement; an auth-required hub still 401s unauthenticated connections. diff --git a/hub-client/e2e/local-first.spec.ts b/hub-client/e2e/local-first.spec.ts index 55169ff85..6387e2830 100644 --- a/hub-client/e2e/local-first.spec.ts +++ b/hub-client/e2e/local-first.spec.ts @@ -4,8 +4,12 @@ * Verifies the headline of the connection-gated local-first work through a * real browser: with no IdP configured, the SPA opens straight into a usable * project selector (no login gate), a project can be created fully locally - * (no sync server), and it persists across a reload. The account-level - * control offers "Connect to a hub" rather than gating the whole app. + * (sync server field cleared), and it persists across a reload. The + * account-level control offers "Connect to a hub" rather than gating the + * whole app. The Create form's Sync Server URL field is editable and + * defaults to DEFAULT_SYNC_SERVER (restored per bd-u4p8xhdc follow-up) — this + * test clears it explicitly so project creation stays offline instead of + * contacting the real wss://sync.automerge.org configured via `.env`. * * The hub-connect leg (sign in, open/create a hub project) requires a live * OIDC provider and is verified manually — see the plan's A7v1 notes. @@ -40,8 +44,11 @@ test.describe('Local-first (connection-gated auth)', () => { await expect(typeSelect).toBeVisible(); await typeSelect.selectOption({ index: 0 }); - // The create form must not ask for a sync server — Create is local. - await expect(page.getByLabel(/sync server url/i)).toHaveCount(0); + // The create form's Sync Server URL field is editable; clear it so the + // project is created local-only instead of targeting a real server. + const syncServerInput = page.getByLabel(/sync server url/i); + await expect(syncServerInput).toBeVisible(); + await syncServerInput.fill(''); const title = `Local Project ${Date.now()}`; await page.locator('#projectTitle').fill(title); diff --git a/hub-client/src/components/ProjectSelector.connect.test.tsx b/hub-client/src/components/ProjectSelector.connect.test.tsx index 3e04a8aa6..548a27469 100644 --- a/hub-client/src/components/ProjectSelector.connect.test.tsx +++ b/hub-client/src/components/ProjectSelector.connect.test.tsx @@ -4,8 +4,8 @@ * The account-level control shows "Connect to a hub" when disconnected * (triggering sign-in) and the signed-in identity + Sign out when connected. * It is deliberately separate from the per-project "Connect to Project" - * (join-by-doc-id) action. The create form no longer asks for a sync server — - * Create defaults to local. + * (join-by-doc-id) action. The Create/Import forms' Sync Server URL field is + * covered in ProjectSelector.create.test.tsx / ProjectSelector.import.test.tsx. * * @vitest-environment jsdom */ @@ -83,10 +83,4 @@ describe('ProjectSelector hub-connection header control', () => { // The disconnected affordance is gone. expect(screen.queryByRole('button', { name: /^connect to a hub$/i })).toBeNull(); }); - - it('the create form no longer asks for a sync server (local default)', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /create new project/i })); - expect(screen.queryByLabelText(/sync server url/i)).toBeNull(); - }); }); diff --git a/hub-client/src/components/ProjectSelector.create.test.tsx b/hub-client/src/components/ProjectSelector.create.test.tsx new file mode 100644 index 000000000..2ed7932b5 --- /dev/null +++ b/hub-client/src/components/ProjectSelector.create.test.tsx @@ -0,0 +1,105 @@ +/** + * Tests for the "Create New Project" form's Sync Server URL field. + * + * The field defaults to DEFAULT_SYNC_SERVER (matching the Connect form) and + * is editable — restored after a brief removal during the local-first epic + * (bd-u4p8xhdc); see claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md. + * Clearing the field still creates a local-only project (empty syncServer). + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +const { createProjectMock } = vi.hoisted(() => ({ createProjectMock: vi.fn() })); + +vi.mock('@quarto/preview-runtime', () => ({ + getProjectChoices: vi.fn().mockResolvedValue([ + { id: 'website', name: 'Website', description: 'A basic Quarto website' }, + ]), + createProject: createProjectMock, + importProjectFromZip: vi.fn(), +})); + +vi.mock('../services/projectStorage', () => ({ + listProjects: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../services/userSettings', () => ({ + getUserIdentity: vi.fn().mockResolvedValue(null), + updateUserName: vi.fn(), + updateUserColor: vi.fn(), + resetUserIdentity: vi.fn(), +})); + +vi.mock('./ThemeContext', () => ({ + useTheme: () => ({ colorScheme: 'auto', cycleColorScheme: vi.fn() }), +})); + +import ProjectSelector from './ProjectSelector'; + +function renderSelector(onProjectCreated = vi.fn()) { + render( + , + ); + return { onProjectCreated }; +} + +afterEach(cleanup); + +describe('ProjectSelector — Create New Project sync server field', () => { + beforeEach(() => { + vi.clearAllMocks(); + createProjectMock.mockResolvedValue({ + success: true, + files: [{ path: 'index.qmd', content_type: 'text', content: '# Hi' }], + }); + }); + + it('shows a Sync Server URL field defaulting to DEFAULT_SYNC_SERVER', async () => { + renderSelector(); + fireEvent.click(await screen.findByRole('button', { name: /create new project/i })); + + const input = (await screen.findByLabelText(/sync server url/i)) as HTMLInputElement; + expect(input.value).toBe('wss://sync.automerge.org'); + }); + + it('is editable and the edited value flows to onProjectCreated', async () => { + const { onProjectCreated } = renderSelector(); + fireEvent.click(await screen.findByRole('button', { name: /create new project/i })); + + await screen.findByLabelText(/project type/i); + fireEvent.change(screen.getByLabelText(/sync server url/i), { + target: { value: 'wss://my-hub.example.com/ws' }, + }); + fireEvent.change(screen.getByLabelText('Project Title'), { + target: { value: 'My Project' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^create project$/i })); + + await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1)); + const [, , , syncServer] = onProjectCreated.mock.calls[0]; + expect(syncServer).toBe('wss://my-hub.example.com/ws'); + }); + + it('clearing the field still creates a local-only project (empty syncServer)', async () => { + const { onProjectCreated } = renderSelector(); + fireEvent.click(await screen.findByRole('button', { name: /create new project/i })); + + await screen.findByLabelText(/project type/i); + fireEvent.change(screen.getByLabelText(/sync server url/i), { target: { value: '' } }); + fireEvent.change(screen.getByLabelText('Project Title'), { + target: { value: 'Local Project' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^create project$/i })); + + await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1)); + const [, , , syncServer] = onProjectCreated.mock.calls[0]; + expect(syncServer).toBe(''); + }); +}); diff --git a/hub-client/src/components/ProjectSelector.import.test.tsx b/hub-client/src/components/ProjectSelector.import.test.tsx index 7eb4f30db..bf929d942 100644 --- a/hub-client/src/components/ProjectSelector.import.test.tsx +++ b/hub-client/src/components/ProjectSelector.import.test.tsx @@ -132,13 +132,31 @@ describe('ProjectSelector — Import from ZIP', () => { expect(passedBytes).toBeInstanceOf(Uint8Array); expect(Array.from(passedBytes)).toEqual(Array.from(ZIP_BYTES)); - // The parsed files + form values flow to the create callback. Import now - // defaults to local (empty sync server) when no hub project set is - // connected — the local-first default (bd-u4p8xhdc). + // The parsed files + form values flow to the create callback, including + // the (editable) Sync Server URL field's current value. const [files, title, projectType, syncServer] = onProjectCreated.mock.calls[0]; expect(files).toEqual(parsed); expect(title).toBe('My Project'); expect(projectType).toBe('imported'); + expect(syncServer).toBe('wss://sync.automerge.org'); + }); + + it('lets the sync server field be edited, including cleared for a local-only import', async () => { + const parsed = [{ path: 'index.qmd', content_type: 'text', content: '# Hi' }]; + importMock.mockReturnValue(parsed); + + const { onProjectCreated } = renderSelector(); + fireEvent.click(await screen.findByRole('button', { name: /Import from ZIP/i })); + + const syncServerInput = screen.getByLabelText(/sync server url/i) as HTMLInputElement; + expect(syncServerInput.value).toBe('wss://sync.automerge.org'); + fireEvent.change(syncServerInput, { target: { value: '' } }); + + setInputFiles(screen.getByLabelText('ZIP File'), [zipFile('My Project.zip')]); + fireEvent.click(screen.getByRole('button', { name: /Import Project/i })); + + await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1)); + const [, , , syncServer] = onProjectCreated.mock.calls[0]; expect(syncServer).toBe(''); }); diff --git a/hub-client/src/components/ProjectSelector.tsx b/hub-client/src/components/ProjectSelector.tsx index 23e1346ee..69d1edb52 100644 --- a/hub-client/src/components/ProjectSelector.tsx +++ b/hub-client/src/components/ProjectSelector.tsx @@ -367,9 +367,8 @@ export default function ProjectSelector({ return; } - // Create targets the active project set: a hub server when connected to - // one, otherwise local-only (empty string) — the local-first default. - const createTargetServer = projectSetSyncServer ?? ''; + // Empty targets local-only creation; otherwise a hub sync server. + const createTargetServer = syncServer.trim(); setIsCreating(true); @@ -445,9 +444,8 @@ export default function ProjectSelector({ return; } - // Import targets the active project set (hub server when connected, - // otherwise local-only — the local-first default). - const importTargetServer = projectSetSyncServer ?? ''; + // Empty targets local-only creation; otherwise a hub sync server. + const importTargetServer = syncServer.trim(); setIsImporting(true); @@ -750,6 +748,16 @@ export default function ProjectSelector({ autoFocus /> +
+ + setSyncServer(e.target.value)} + placeholder="wss://sync.automerge.org" + /> +
+
+ + setSyncServer(e.target.value)} + placeholder="wss://sync.automerge.org" + /> +
@@ -800,8 +811,8 @@ export default function ProjectSelector({ setSyncServer(e.target.value)} + value={newProjectSyncServer} + onChange={(e) => setNewProjectSyncServer(e.target.value)} placeholder="wss://sync.automerge.org" /> From cf6c76ad3f4b79fec7f44b59669d655133188bb3 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:32:21 +0100 Subject: [PATCH 15/16] hub-client+sync-client: fix flash-and-bounce on logged-off hub opens (bd-lficgfwi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same regression against B1 (bd-qklxdkwh): App's auth-loss teardown was state-based (!auth && hub project → disconnect), unmounting every logged-off cached-hub open on the next render; and createNewProject swallowed resolveActorId's null, silently creating a hub-wired project with no session for the teardown to kill. Teardown is now transition-based (shouldTeardownOnAuthChange, ref-tracked hadAuth); the sync client aborts creation with a typed ActorAuthRequiredError which App turns into a sign-in prompt; a consumed showLogin is reset on successful sign-in. --- ...-hub-client-offline-cached-hub-projects.md | 37 ++++++++ hub-client/src/App.tsx | 40 +++++++-- hub-client/src/services/authTeardown.test.ts | 51 +++++++++++ hub-client/src/services/authTeardown.ts | 24 +++++ .../src/client.actor-auth.test.ts | 88 +++++++++++++++++++ ts-packages/quarto-sync-client/src/client.ts | 33 ++++++- ts-packages/quarto-sync-client/src/index.ts | 1 + 7 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 hub-client/src/services/authTeardown.test.ts create mode 100644 hub-client/src/services/authTeardown.ts create mode 100644 ts-packages/quarto-sync-client/src/client.actor-auth.test.ts diff --git a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md index 5b7566598..7fdcedf0b 100644 --- a/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -231,6 +231,43 @@ returns rather than once. Network → Offline, reload (opens from cache, editable), edit, go back online, confirm the edit reaches the hub and authorship shows one author. +## Follow-up fixes (post-B5) + +- **State-based auth teardown killed every logged-off hub open** (interactive + testing, 2026-07-17). B1 opens a cached hub project logged-off under the + local actor — but App.tsx's auth-loss teardown was *state*-based + (`AUTH_ENABLED && !auth && project?.syncServer` → disconnect), so the + project unmounted on the very next render: a flash of the editor, then the + selector. Symptoms reported: clicking any hub project while signed out + flashed and bounced; same after sign-out. None of B1's tests caught it — + they cover the `openActor` seam and the sync client, and the E2E harness + builds with auth disabled, so the App-level effect interplay was never + exercised. Fix: teardown is now *transition*-based via + `shouldTeardownOnAuthChange` (`src/services/authTeardown.ts`, extracted + + unit-tested): it fires only on a genuine loss (`hadAuth && !hasAuth`, + tracked with a ref), still only for hub-backed projects. Sign-out with a + doc open still returns to the selector (deliberate transition), but + reopening from the selector now works offline per B1. + +- **Hub create without a session silently created-then-flashed.** The same + interactive round found `createNewProject`'s `resolveActorId` callback + swallowed `null` (`?? undefined`, violating the documented three-valued + contract), so creating a project targeting a hub while logged off created + the docs anyway, wired to a live WS adapter — then the (then state-based) + teardown killed it. Fix in `quarto-sync-client`: `null` now aborts creation + with a typed `ActorAuthRequiredError` before any document exists + (`client.actor-auth.test.ts` pins the three-valued contract); App catches + it and prompts sign-in ("Sign in to create a project on this hub."). Also + fixed while wiring: a consumed `showLogin` is reset on successful sign-in, + so a later sign-out lands on the selector, not a stale LoginScreen. + +- **Open UX question (filed, not done):** should sign-out with a cached hub + project open keep the project open (reverse-switch to the local actor in + place, the mirror of B3) instead of returning to the selector? The B5 + manual recipe ("sign out … edit … sign back in") leans yes; current + behavior (teardown on genuine sign-out, reopen from selector works) is the + conservative v1. + ## Interaction with the shipped v1 - **Supersedes** the interim prompt-sign-in behavior for *cached* hub projects diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 098384cf5..dad26ecef 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -32,7 +32,7 @@ import { type EditorContentChange, } from '@quarto/preview-runtime'; import type { ProjectFile } from '@quarto/preview-runtime'; -import { isDocCached } from '@quarto/quarto-sync-client'; +import { isDocCached, ActorAuthRequiredError } from '@quarto/quarto-sync-client'; import * as projectStorage from './services/projectStorage'; import { installDebugApi } from './services/debugApi'; import { getUserIdentity, updateUserName, getOrCreateLocalActor } from './services/userSettings'; @@ -43,6 +43,7 @@ import { useAuthProbe } from './hooks/useAuthProbe'; import { useExecutionChannel } from './hooks/useExecutionChannel'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import { resolveActorForOpen as resolveActorForOpenImpl } from './services/openActor'; +import { shouldTeardownOnAuthChange } from './services/authTeardown'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; import { resolveSyncServerUrl } from './utils/routing'; import './App.css'; @@ -117,6 +118,12 @@ function App() { // `showLogin` renders the sign-in screen as an opt-in overlay. const [showLogin, setShowLogin] = useState(false); + // A successful sign-in consumes the request; without this reset a stale + // `showLogin` re-gates the app into the LoginScreen on the next sign-out. + useEffect(() => { + if (auth) setShowLogin(false); + }, [auth]); + // bd-sfet3264 (Phase 2D + Phase 4b): track which q2 executors are online for // the connected project (via the index-handle capability beacon) and expose // a way to ask one to run a document. Beacons come from a connected @@ -473,18 +480,29 @@ function App() { loadFromUrl(); }, [route, navigateToProjectSelector, navigateToProject, navigateToFile]); - // Disconnect sync when auth is lost (token expired or user logged out) — - // but ONLY for a hub-backed project (one with a sync server). A local - // project has no server and no auth dependency, so an auth-loss event must - // never tear it down (bd-u4p8xhdc). + // Disconnect sync when auth is LOST (token expired or user logged out) — + // a transition from signed-in to signed-out, not the mere state of being + // signed out with a hub project open. A cached hub project deliberately + // opens logged-off under the local actor (B1, bd-qklxdkwh) and must stay + // open; a local project (no sync server) is never torn down (bd-u4p8xhdc). + const hadAuthRef = useRef(!!auth); useEffect(() => { - if (AUTH_ENABLED && !auth && !authLoading && project?.syncServer) { + const hadAuth = hadAuthRef.current; + hadAuthRef.current = !!auth; + if ( + shouldTeardownOnAuthChange({ + authEnabled: AUTH_ENABLED, + hadAuth, + hasAuth: !!auth, + authLoading, + projectSyncServer: project?.syncServer, + }) + ) { disconnect(); setProject(null); setFiles([]); setFileContents(new Map()); setConnectionError(null); - } }, [auth, authLoading, project]); @@ -672,6 +690,14 @@ function App() { // Update URL to reflect the new project navigateToProject(projectEntry.id, { replace: true }); } catch (err) { + // Creating on a hub needs a session: the sync client aborts (before + // any document exists) when the actor resolves to null — prompt + // sign-in instead of surfacing a raw error. + if (err instanceof ActorAuthRequiredError) { + setConnectionError('Sign in to create a project on this hub.'); + setShowLogin(true); + return; + } setConnectionError(err instanceof Error ? err.message : String(err)); } finally { setIsConnecting(false); diff --git a/hub-client/src/services/authTeardown.test.ts b/hub-client/src/services/authTeardown.test.ts new file mode 100644 index 000000000..93fd71e76 --- /dev/null +++ b/hub-client/src/services/authTeardown.test.ts @@ -0,0 +1,51 @@ +/** + * Auth-loss teardown decision (extracted from App.tsx). + * + * The teardown must fire only on a genuine auth LOSS — a session that + * existed is now gone — and only for a hub-backed project. The pre-B1 + * state-based rule ("no auth && hub project open" → tear down) killed + * B1's headline feature (bd-qklxdkwh): a cached hub project deliberately + * opened while logged off, under the local actor, was unmounted on the + * very next render — a flash of the editor, then back to the selector. + * + * Plan: claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md + */ + +import { describe, it, expect } from 'vitest'; + +import { shouldTeardownOnAuthChange } from './authTeardown'; + +const base = { + authEnabled: true, + hadAuth: true, + hasAuth: false, + authLoading: false, + projectSyncServer: 'wss://hub.example.com/ws' as string | undefined, +}; + +describe('shouldTeardownOnAuthChange', () => { + it('tears down a hub project on a genuine auth loss (signed in → signed out)', () => { + expect(shouldTeardownOnAuthChange(base)).toBe(true); + }); + + it('does NOT tear down a hub project opened while already logged off (B1 offline open)', () => { + expect(shouldTeardownOnAuthChange({ ...base, hadAuth: false })).toBe(false); + }); + + it('never tears down a local project (no sync server)', () => { + expect(shouldTeardownOnAuthChange({ ...base, projectSyncServer: undefined })).toBe(false); + expect(shouldTeardownOnAuthChange({ ...base, projectSyncServer: '' })).toBe(false); + }); + + it('does not fire while the auth probe is still loading', () => { + expect(shouldTeardownOnAuthChange({ ...base, authLoading: true })).toBe(false); + }); + + it('does not fire when auth is disabled at build time', () => { + expect(shouldTeardownOnAuthChange({ ...base, authEnabled: false })).toBe(false); + }); + + it('does not fire while a session is present', () => { + expect(shouldTeardownOnAuthChange({ ...base, hasAuth: true })).toBe(false); + }); +}); diff --git a/hub-client/src/services/authTeardown.ts b/hub-client/src/services/authTeardown.ts new file mode 100644 index 000000000..3ce04134f --- /dev/null +++ b/hub-client/src/services/authTeardown.ts @@ -0,0 +1,24 @@ +/** + * Auth-loss teardown decision for the currently-open project. + * + * Extracted from App.tsx so the transition semantics are pinned by unit + * tests: teardown requires a genuine auth LOSS (`hadAuth && !hasAuth`), + * not merely the state "logged off with a hub project open" — a cached + * hub project legitimately opens logged-off under the local actor + * (B1, bd-qklxdkwh) and must stay open. + */ +export function shouldTeardownOnAuthChange(args: { + authEnabled: boolean; + hadAuth: boolean; + hasAuth: boolean; + authLoading: boolean; + projectSyncServer: string | undefined | null; +}): boolean { + return ( + args.authEnabled && + args.hadAuth && + !args.hasAuth && + !args.authLoading && + !!args.projectSyncServer + ); +} diff --git a/ts-packages/quarto-sync-client/src/client.actor-auth.test.ts b/ts-packages/quarto-sync-client/src/client.actor-auth.test.ts new file mode 100644 index 000000000..03b814218 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/client.actor-auth.test.ts @@ -0,0 +1,88 @@ +/** + * createNewProject must honor the resolveActorId three-valued contract + * (see authService.resolveActorId in hub-client): + * string → actor resolved; author under it + * undefined → auth disabled; proceed with no actor + * null → auth failure; ABORT the creation + * + * Previously null was swallowed (`?? undefined`), silently creating a + * hub-wired project with no session — the app then tore it down on the + * next render (a "flash" of the editor before bouncing to the selector). + * + * No mocks: the "server" is a port nothing listens on (offline fallback), + * so the actor step is reached exactly as in the browser. + */ + +import { describe, it, expect } from 'vitest'; + +import { createSyncClient, ActorAuthRequiredError } from './client.js'; + +// Port 9 (discard) on localhost: connection refused immediately. +const UNREACHABLE = 'ws://127.0.0.1:9/ws'; + +function client() { + return createSyncClient({ + onFileAdded: () => {}, + onFileChanged: () => {}, + onFileRemoved: () => {}, + }); +} + +describe('createNewProject + resolveActorId contract', () => { + it('rejects with ActorAuthRequiredError when resolveActorId returns null', async () => { + const c = client(); + await expect( + c.createNewProject( + { + syncServer: UNREACHABLE, + files: [{ path: 'a.qmd', content: 'x', contentType: 'text' }], + storage: 'memory', + peerTimeoutMs: 50, + }, + undefined, + 'Someone', + '#123456', + async () => null, + ), + ).rejects.toBeInstanceOf(ActorAuthRequiredError); + // Nothing was created: the client holds no readable project state. + expect(c.getFileContent('a.qmd')).toBeNull(); + }); + + it('proceeds with no actor when resolveActorId returns undefined (auth disabled)', async () => { + const c = client(); + const result = await c.createNewProject( + { + syncServer: UNREACHABLE, + files: [{ path: 'a.qmd', content: 'x', contentType: 'text' }], + storage: 'memory', + peerTimeoutMs: 50, + }, + undefined, + 'Someone', + '#123456', + async () => undefined, + ); + expect(result.indexDocId).toBeTruthy(); + await c.disconnect(); + }); + + it('authors under the actor when resolveActorId returns a string', async () => { + const c = client(); + const actor = 'aa'.repeat(16); + await c.createNewProject( + { + syncServer: UNREACHABLE, + files: [{ path: 'a.qmd', content: 'x', contentType: 'text' }], + storage: 'memory', + peerTimeoutMs: 50, + }, + undefined, + 'Someone', + '#123456', + async () => actor, + ); + expect(c.getActorId()).toBe(actor); + await c.disconnect(); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index d458e3ab1..24180b5e5 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -67,6 +67,23 @@ export class PeerUnavailableError extends Error { } } +/** + * Actor resolution reported an auth failure (`resolveActorId` → null) + * during project creation. Per the three-valued contract (string / + * undefined / null), null must ABANDON the operation — creating a + * hub-wired project with no session produces a doc the app immediately + * tears down. Callers catch this to prompt sign-in. + */ +export class ActorAuthRequiredError extends Error { + override readonly name = 'ActorAuthRequiredError'; + constructor(indexDocId: string) { + super( + `actor resolution returned null (auth required) for ${indexDocId}; ` + + 'aborting project creation', + ); + } +} + /** * Normalize a document id from the index into the `automerge:` * URL form that `repo.find()` requires (bd-4uvv). `String(...)` @@ -1565,9 +1582,19 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS const indexUrl = generateAutomergeUrl(); const { documentId: indexDocId } = parseAutomergeUrl(indexUrl); - const resolvedActorId = resolveActorId - ? (await resolveActorId(indexDocId)) ?? undefined - : actorId; + let resolvedActorId: string | undefined; + if (resolveActorId) { + const resolved = await resolveActorId(indexDocId); + // null = auth failure → abort (three-valued contract); undefined = + // auth disabled → proceed with no actor. + if (resolved === null) { + await disconnect(); + throw new ActorAuthRequiredError(indexDocId); + } + resolvedActorId = resolved; + } else { + resolvedActorId = actorId; + } state.actorId = resolvedActorId ?? null; // Phase 2: Create the index document via createDoc with the diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index 5d04100eb..5f40d0d5c 100644 --- a/ts-packages/quarto-sync-client/src/index.ts +++ b/ts-packages/quarto-sync-client/src/index.ts @@ -53,6 +53,7 @@ export type { export { createSyncClient, PeerUnavailableError, + ActorAuthRequiredError, // Locked unavailability wording (bd-vm5e5u10) — reused by hub-mcp // so per-file tool errors match the sync client's diagnostics. fileUnavailableMessage, From c556487286bdfdf10c5c85bf854aba19e607b31a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:45:54 +0100 Subject: [PATCH 16/16] hub-client e2e: fix project-set bootstrap for local-first first-load Connection-gated local-first (bd-u4p8xhdc) auto-mints a local project set on fresh load, so the "Create New Project Set" onboarding screen no longer appears. Update bootstrapProjectSet and the two inline copies to wait for the selector directly, and harden a pre-existing __quartoTest poll race in the share-link spec. Full e2e suite: 57 passed. --- hub-client/e2e/helpers/projectFactory.ts | 40 ++++++++-------- hub-client/e2e/import-zip.spec.ts | 18 +++----- hub-client/e2e/share-link-project-set.spec.ts | 46 ++++++++----------- 3 files changed, 44 insertions(+), 60 deletions(-) diff --git a/hub-client/e2e/helpers/projectFactory.ts b/hub-client/e2e/helpers/projectFactory.ts index a01fbaa79..a5486f7d9 100644 --- a/hub-client/e2e/helpers/projectFactory.ts +++ b/hub-client/e2e/helpers/projectFactory.ts @@ -129,16 +129,20 @@ async function waitForServerDocuments( } /** - * Drive the first-time-setup UI to create a synced project set on the running - * hub server and store its pointer in the browser's IDB. Must be called once - * per fresh Playwright browser context before {@link seedProjectInBrowser}, - * otherwise the legacy project entry triggers the "Upgrade: Synced Project - * List" migration screen and blocks all navigation. + * Bring a fresh Playwright browser context to the project selector, ready for + * {@link seedProjectInBrowser}. Must be called once per fresh context so the + * Monaco/auth/preference stubs are installed before any project loads. * - * Modeled after the share-link spec's `bootstrapReceiverProjectSet` — driving - * the UI lets the app's own `useProjectSet` race-free state machine put us in - * `connected` status rather than racing a hand-rolled createProjectSet call - * against the migration check. + * Connection-gated local-first (bd-u4p8xhdc): there is no first-time-setup + * screen anymore. `useProjectSet` auto-mints a local project set (empty + * syncServer, no network) on a fresh browser and goes straight to `connected`, + * so the selector renders immediately with no login and no server round-trip. + * + * The set being local is irrelevant to these tests: each project seeded by + * {@link seedProjectInBrowser} keeps its own hub `syncServer` (its docs live + * on the hub, created via {@link createProjectOnServer}), and + * `projectSet.isConnected()` is peer-independent (`handle !== null`), so + * seeding into the local set works exactly as it did into a synced one. */ // Monaco loads from CDN (cdn.jsdelivr.net) by default in the production build. // Route those requests to local node_modules so tests work offline and in @@ -178,7 +182,9 @@ async function interceptMonacoCdn(page: Page): Promise { export async function bootstrapProjectSet( page: Page, - syncServer: string, + // Retained for a stable call signature (callers also pass this to + // seedProjectInBrowser); the selector no longer takes a sync-server input. + _syncServer: string, ): Promise { await mockAuthMe(page); await interceptMonacoCdn(page); @@ -245,15 +251,8 @@ export async function bootstrapProjectSet( ' npm run test:e2e\n', ); } - await expect( - page.getByText(/Get started by creating a new project set/i), - ).toBeVisible(); - - await page.locator('#setup-sync-server').fill(syncServer); - await page - .getByRole('button', { name: /Create New Project Set/i }) - .click(); - + // The app auto-mints a local project set and lands on the selector — no + // setup screen to drive. Wait for the selector heading before seeding. await expect( page.getByRole('heading', { name: 'Your Projects' }), ).toBeVisible({ timeout: 20000 }); @@ -263,8 +262,7 @@ export async function bootstrapProjectSet( * Seed a project entry in the browser's IndexedDB so the app can load it. * * Call {@link bootstrapProjectSet} once per browser context first so the - * synced project set is initialized; otherwise the App lands on the - * needs-migration screen. + * project set is initialized and the app has settled on the selector. * * Returns the local project ID (UUID) used in URL navigation. */ diff --git a/hub-client/e2e/import-zip.spec.ts b/hub-client/e2e/import-zip.spec.ts index 6226fc77a..19d3c75c7 100644 --- a/hub-client/e2e/import-zip.spec.ts +++ b/hub-client/e2e/import-zip.spec.ts @@ -14,20 +14,14 @@ import { zipSync, strToU8 } from 'fflate'; import { getServerUrl } from './helpers/projectFactory'; /** - * Bring a fresh browser context to the project-selector landing page with - * a connected project set, so the "Import from ZIP" action is available. - * Mirrors the bootstrap in share-link-project-set.spec.ts. + * Bring a fresh browser context to the project-selector landing page, so the + * "Import from ZIP" action is available. Connection-gated local-first + * (bd-u4p8xhdc): the app auto-mints a local project set and lands on the + * selector directly — no setup screen to drive. */ -async function bootstrapProjectSet(page: Page, syncServer: string): Promise { +async function bootstrapProjectSet(page: Page): Promise { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Quarto Hub' })).toBeVisible(); - await expect( - page.getByText(/Get started by creating a new project set/i), - ).toBeVisible(); - - await page.locator('#setup-sync-server').fill(syncServer); - await page.getByRole('button', { name: /Create New Project Set/i }).click(); - await expect(page.getByRole('heading', { name: 'Your Projects' })).toBeVisible({ timeout: 20000, }); @@ -73,7 +67,7 @@ test.describe('Import from ZIP', () => { test('creates a new project from an uploaded ZIP and renders it', async ({ page }) => { const syncServer = getServerUrl(); - await bootstrapProjectSet(page, syncServer); + await bootstrapProjectSet(page); // Open the import form. await page.getByRole('button', { name: /Import from ZIP/i }).click(); diff --git a/hub-client/e2e/share-link-project-set.spec.ts b/hub-client/e2e/share-link-project-set.spec.ts index cea5d24b5..aafd5622d 100644 --- a/hub-client/e2e/share-link-project-set.spec.ts +++ b/hub-client/e2e/share-link-project-set.spec.ts @@ -24,41 +24,28 @@ import { import type {} from './helpers/testHooks'; /** - * Bootstrap the receiver browser with an existing synced project set. + * Bootstrap the receiver browser with a connected project set. * * This is the critical precondition for reproducing the bug: when the user * already has a project set connected, ProjectSelector renders its list from - * the synced entries (not from IDB), so a share-flow entry that only makes it + * the set entries (not from IDB), so a share-flow entry that only makes it * into IDB is invisible. * - * We drive the UI rather than poking IDB/Automerge directly — this lets the - * app's own `createProjectSet` → `setProjectSetPointer` → `setStatus('connected')` - * sequence happen atomically inside `useProjectSet`, which sidesteps the race - * where a hand-rolled createProjectSet + reload can land before the server - * has acknowledged the new doc. + * Connection-gated local-first (bd-u4p8xhdc): the app auto-mints a local + * project set on a fresh browser and lands on the selector directly — no + * setup screen to drive. The set being local rather than hub-synced does not + * change the bug's shape: the reconciler adopts orphan IDB entries into the + * connected set regardless (`isConnected()` is peer-independent), and the + * shared project keeps its own hub syncServer so it still opens from the hub. */ -async function bootstrapReceiverProjectSet( - page: Page, - syncServer: string, -): Promise { +async function bootstrapReceiverProjectSet(page: Page): Promise { await page.goto('/'); await expect(page.locator('body')).toBeVisible(); - // Fresh browser context lands on the first-time-setup screen. + // The app auto-mints a local project set and renders the selector directly. await expect( page.getByRole('heading', { name: 'Quarto Hub' }), ).toBeVisible(); - await expect( - page.getByText(/Get started by creating a new project set/i), - ).toBeVisible(); - - // Point at the local hub server (the input defaults to the public server). - await page.locator('#setup-sync-server').fill(syncServer); - await page - .getByRole('button', { name: /Create New Project Set/i }) - .click(); - - // When the project set finishes connecting, ProjectSelector renders. await expect( page.getByRole('heading', { name: 'Your Projects' }), ).toBeVisible({ timeout: 20000 }); @@ -125,7 +112,7 @@ test.describe('Share link → synced project set', () => { // Step 1: bootstrap the receiver's existing synced project set in a // throwaway page, then close it. const bootstrapPage = await receiver.newPage(); - await bootstrapReceiverProjectSet(bootstrapPage, syncServer); + await bootstrapReceiverProjectSet(bootstrapPage); await bootstrapPage.close(); // Step 2: open the share URL in a fresh page. The synced project set @@ -183,7 +170,7 @@ test.describe('Share link → synced project set', () => { try { // Bootstrap in a throwaway page — see the explanation in the other test. const bootstrapPage = await receiver.newPage(); - await bootstrapReceiverProjectSet(bootstrapPage, syncServer); + await bootstrapReceiverProjectSet(bootstrapPage); await bootstrapPage.close(); // Visit the share link in a fresh page so the App.tsx share handler @@ -202,9 +189,14 @@ test.describe('Share link → synced project set', () => { .poll( () => sharePage.evaluate(async (docId) => { + // On a freshly-navigated page, main.tsx may not have set + // `__quartoTestReady` yet when this first fires (the documented + // race in main.tsx). Inside a poll, treat not-ready as + // "retry" rather than throwing and failing the whole poll — + // the fail-loud VITE_E2E guard still lives in the helpers. await window.__quartoTestReady; const hooks = window.__quartoTest; - if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1'); + if (!hooks) return false; const entry = await hooks.projectStorage.getProjectByIndexDocId(`automerge:${docId}`); return !!entry; }, sharedIndexDocId), @@ -265,7 +257,7 @@ test.describe('Share link → synced project set', () => { try { const bootstrapPage = await receiver.newPage(); - await bootstrapReceiverProjectSet(bootstrapPage, syncServer); + await bootstrapReceiverProjectSet(bootstrapPage); // Seed IDB directly with an entry that the synced project set knows // nothing about — this is exactly the state Bug A produces.