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..ab83f252d --- /dev/null +++ b/claude-notes/plans/2026-07-06-hub-client-connection-gated-local-first.md @@ -0,0 +1,190 @@ +# 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 + +- [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.* +- [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.)* +- [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`. + +## 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.)* + +- **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 and editable. Tests: `ProjectSelector.create.test.tsx` + (new), `ProjectSelector.import.test.tsx`, `ProjectSelector.connect.test.tsx` + (updated); `e2e/local-first.spec.ts` updated. + +- **Regression: unconditional `DEFAULT_SYNC_SERVER` default broke local + creation** (interactive testing, follow-up to bd-ivkf752c). The field + restoration above initially defaulted to `DEFAULT_SYNC_SERVER` + unconditionally (matching the Connect form). That silently turned local + creation into a hub-creation attempt whenever the user didn't clear the + field: `isLocal = !syncServer` in `App.tsx` saw a non-empty value and took + the hub path; `createNewProject`'s `resolveActorId` callback got a 401 (no + session) but `client.ts:1568-1571` swallows that (`?? undefined`) instead + of aborting, so the project was created anyway, wired to a real WS + adapter — then immediately torn down by the auth-loss-teardown effect + (`App.tsx:480-489`, correctly acting on the now-truthy `syncServer`). User- + visible symptom: a flash of the editor on create *or* on reopening that + project, then bounced back to the selector. Fix: the field now defaults to + `projectSetSyncServer ?? ''` (empty/local when not connected to a hub, the + connected hub's server when connected) via new state + `newProjectSyncServer`, reset on each Create/Import form open — separate + from the Connect form's own `syncServer` state, which legitimately keeps + defaulting to `DEFAULT_SYNC_SERVER` (joining an existing project always + needs a real server). Tests updated in the same three files plus + `e2e/local-first.spec.ts`, which now asserts the empty default directly + instead of force-clearing the field — the prior version of that test + masked exactly this regression. + +## 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`. 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..7fdcedf0b --- /dev/null +++ b/claude-notes/plans/2026-07-15-hub-client-offline-cached-hub-projects.md @@ -0,0 +1,337 @@ +# 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`) + +- [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. +- [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. +- [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. + - **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 + 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. + - **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. + +## 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 + (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~~ + **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). + 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). diff --git a/hub-client/LOCAL-FIRST.md b/hub-client/LOCAL-FIRST.md new file mode 100644 index 000000000..b52fbe656 --- /dev/null +++ b/hub-client/LOCAL-FIRST.md @@ -0,0 +1,77 @@ +# 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. + +## 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 | — | ✅ | + +## 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/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/local-first.spec.ts b/hub-client/e2e/local-first.spec.ts new file mode 100644 index 000000000..00302f3e3 --- /dev/null +++ b/hub-client/e2e/local-first.spec.ts @@ -0,0 +1,75 @@ +/** + * 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, + * 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 (restored per + * bd-u4p8xhdc follow-up bd-ivkf752c) but MUST default to empty when not + * connected to a hub — this test asserts that default rather than clearing + * the field, because a non-empty default is exactly the regression that + * shipped once already (bd-ivkf752c follow-up): it silently turns local + * creation into a hub-creation attempt with no session, which + * createNewProject's resolveActorId callback doesn't abort on, so the + * project gets created and wired to a real WS adapter, then immediately + * torn down by App.tsx's auth-loss-teardown effect — a "flash" of the + * editor before bouncing back to the selector. + * + * 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's Sync Server URL field is editable, but must default + // to empty (local) since we're not connected to a hub — left untouched + // here so this test guards the default, not just the override. + const syncServerInput = page.getByLabel(/sync server url/i); + await expect(syncServerInput).toBeVisible(); + await expect(syncServerInput).toHaveValue(''); + + 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 }); + }); +}); 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. 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/hub-client/src/App.tsx b/hub-client/src/App.tsx index 08db74471..dad26ecef 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -25,20 +25,25 @@ import { getFileContent, applyEditorOperations, createNewProject, + getActorId, + switchActor, type ActorIdentity, type CaptureRef, type EditorContentChange, } from '@quarto/preview-runtime'; import type { ProjectFile } from '@quarto/preview-runtime'; +import { isDocCached, ActorAuthRequiredError } from '@quarto/quarto-sync-client'; 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'; 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'; @@ -108,6 +113,16 @@ 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); + + // 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 @@ -123,8 +138,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 +162,54 @@ 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; + // 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], + ); + + // 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'); @@ -193,6 +258,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 = @@ -256,7 +327,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 +455,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,17 +480,29 @@ 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) — + // 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) { + 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]); @@ -472,23 +555,30 @@ 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); 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 +596,7 @@ function App() { } finally { setIsConnecting(false); } - }, [navigateToProject, navigateToFile, resolveActorId, screenName, cursorColor]); + }, [navigateToProject, navigateToFile, resolveActorForOpen, screenName, cursorColor]); const handleDisconnect = useCallback(async () => { await disconnect(); @@ -540,31 +630,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 { @@ -588,27 +690,32 @@ 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); } }, [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 +779,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..548a27469 --- /dev/null +++ b/hub-client/src/components/ProjectSelector.connect.test.tsx @@ -0,0 +1,86 @@ +/** + * 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/Import forms' Sync Server URL field is + * covered in ProjectSelector.create.test.tsx / ProjectSelector.import.test.tsx. + * + * @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(); + }); +}); 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..052bd4349 --- /dev/null +++ b/hub-client/src/components/ProjectSelector.create.test.tsx @@ -0,0 +1,122 @@ +/** + * Tests for the "Create New Project" form's Sync Server URL field. + * + * The field is editable, but its *default* value must track whether the app + * is currently connected to a hub: empty (local) when disconnected, the + * connected hub's server when connected. Defaulting unconditionally to + * DEFAULT_SYNC_SERVER (a prior version of this field) silently turned local + * creation into a hub creation attempt with no session — createNewProject's + * resolveActorId callback swallows the resulting 401 (client.ts:1568-1571) + * instead of aborting, so the project was created anyway, wired to a real + * WS adapter, and then immediately torn down by App.tsx's auth-loss-teardown + * effect (a "flash" of the editor before bouncing back to the selector). + * See 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 { 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(), + props: Partial> = {}, +) { + 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('defaults to empty (local) when not connected to a hub', 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(''); + }); + + it('defaults to the connected hub server when a project set is connected', async () => { + renderSelector(vi.fn(), { projectSetSyncServer: 'wss://hub.example.com/ws' }); + 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://hub.example.com/ws'); + }); + + it('leaving the field empty (not connected to a hub) creates a local-only project', 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('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(''); + }); + + it('is editable — a typed value overrides the default and 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'); + }); +}); diff --git a/hub-client/src/components/ProjectSelector.import.test.tsx b/hub-client/src/components/ProjectSelector.import.test.tsx index 653b138e8..f6d2d0916 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,33 @@ 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. Not + // connected to a hub, so the (editable) Sync Server URL field defaults + // to empty — a local-only import. 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('lets the sync server field be edited to target a hub', 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(''); + fireEvent.change(syncServerInput, { target: { value: 'wss://my-hub.example.com/ws' } }); + + 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('wss://my-hub.example.com/ws'); }); 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 1aa4e6dd1..5a58d3d6c 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, @@ -97,6 +103,17 @@ export default function ProjectSelector({ const [projectChoices, setProjectChoices] = useState([]); const [loadingChoices, setLoadingChoices] = useState(false); + // Sync Server URL field shared by Create + Import (new-project forms, as + // opposed to Connect's join-an-existing-project form above). Editable, but + // must default to empty when not connected to a hub: a non-empty default + // silently turns local creation into a hub-creation attempt with no + // session, which createNewProject's resolveActorId callback doesn't abort + // on (client.ts) — the project gets created and wired to a real WS + // adapter, then immediately torn down by the auth-loss-teardown effect. + // Reset on each form open so it tracks projectSetSyncServer if it changes + // mid-session (e.g. the user connects to a hub between opens). + const [newProjectSyncServer, setNewProjectSyncServer] = useState(() => projectSetSyncServer ?? ''); + // Import-from-ZIP form state const [importTitle, setImportTitle] = useState(''); const [importFile, setImportFile] = useState(null); @@ -262,8 +279,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, ); } @@ -358,10 +378,8 @@ export default function ProjectSelector({ return; } - if (!syncServer.trim()) { - setFormError('Sync Server URL is required'); - return; - } + // Empty targets local-only creation; otherwise a hub sync server. + const createTargetServer = newProjectSyncServer.trim(); setIsCreating(true); @@ -385,7 +403,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(', '); @@ -437,10 +455,8 @@ export default function ProjectSelector({ return; } - if (!syncServer.trim()) { - setFormError('Sync Server URL is required'); - return; - } + // Empty targets local-only creation; otherwise a hub sync server. + const importTargetServer = newProjectSyncServer.trim(); setIsImporting(true); @@ -454,7 +470,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); @@ -551,17 +567,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 && ( + + ) )}
@@ -776,8 +811,8 @@ export default function ProjectSelector({ setSyncServer(e.target.value)} + value={newProjectSyncServer} + onChange={(e) => setNewProjectSyncServer(e.target.value)} placeholder="wss://sync.automerge.org" /> diff --git a/hub-client/src/components/auth/LoginScreen.tsx b/hub-client/src/components/auth/LoginScreen.tsx index 0bebde286..559ddb0f4 100644 --- a/hub-client/src/components/auth/LoginScreen.tsx +++ b/hub-client/src/components/auth/LoginScreen.tsx @@ -14,14 +14,23 @@ import { useAuthProvider } from '../../auth/AuthProvider'; import { hubPath } from '../../utils/routing'; -export function LoginScreen({ error, message }: { error?: boolean; message?: string }) { +export function LoginScreen({ + error, + message, + onCancel, +}: { + error?: boolean; + message?: string; + /** When provided, renders a "Back" control that dismisses the sign-in screen. */ + onCancel?: () => void; +}) { const provider = useAuthProvider(); return (
Quarto -

Quarto Hub

+

Connect to a hub

{error ? (

Sign-in failed. Your account is not authorized to access this hub. @@ -38,6 +47,15 @@ export function LoginScreen({ error, message }: { error?: boolean; message?: str + {onCancel && ( + + )}

); 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) { 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/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/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/hub-client/src/services/openActor.test.ts b/hub-client/src/services/openActor.test.ts new file mode 100644 index 000000000..1b5650e9e --- /dev/null +++ b/hub-client/src/services/openActor.test.ts @@ -0,0 +1,124 @@ +/** + * resolveActorForOpen. + * + * 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. + * + * 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, 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 prompts', async () => { + const deps = makeDeps({ + resolveHubActor: vi.fn(async () => null), // must not be consulted + getLocalActor: async () => 'localactor00000000000000000000aa', + }); + const actor = await resolveActorForOpen('automerge:local', '', deps); + expect(actor).toBe('localactor00000000000000000000aa'); + 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 deps = makeDeps({ resolveHubActor: async () => 'hubactor11111111111111111111bbbb' }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBe('hubactor11111111111111111111bbbb'); + expect(deps.onNeedsSignIn).not.toHaveBeenCalled(); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); + }); + + 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, + }); + const actor = await resolveActorForOpen('automerge:hub', 'wss://hub', deps); + expect(actor).toBeNull(); + expect(deps.onNeedsSignIn).toHaveBeenCalledTimes(1); + expect(deps.onCannotOpenOffline).not.toHaveBeenCalled(); + }); + + 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', + }); + 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: 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 new file mode 100644 index 000000000..8997d33ce --- /dev/null +++ b/hub-client/src/services/openActor.ts @@ -0,0 +1,88 @@ +/** + * 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) 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 (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, used for local-only and cached-offline opens. */ + getLocalActor: () => Promise; + /** Server-derived HMAC actor for a hub project (three-valued; throws when offline). */ + resolveHubActor: (indexDocId: string) => Promise; + /** 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( + indexDocId: string, + syncServer: string, + deps: OpenActorDeps, +): Promise { + if (!syncServer) { + // Local-only project — always openable, no session required. + return deps.getLocalActor(); + } + + 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; + } + } + + 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/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/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; +} 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 diff --git a/ts-packages/preview-runtime/src/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts index 6d81e0725..c1070c336 100644 --- a/ts-packages/preview-runtime/src/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -300,6 +300,25 @@ 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 + * 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-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; } 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..69dc7cb86 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/cached-hub-offline.test.ts @@ -0,0 +1,268 @@ +/** + * 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 { getAllChanges, decodeChange, type Doc } from '@automerge/automerge'; + +import { isDocCached } from './storage-adapter.js'; +import { startTestHub, type TestHub } from './test-hub.js'; +import { + createSyncedHubProject, + goOffline, + goOnline, + reopenCachedOffline, + readIdentities, + waitForHubFileText, + waitForOnline, +} 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); + + 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', + }); + + 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); + + 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 new file mode 100644 index 000000000..a0bb25fb2 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/cached-hub.ts @@ -0,0 +1,203 @@ +/** + * 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; + /** 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 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 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: 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). + peerTimeoutMs: 10000, + requireOnline: true, + }, + opts.actor, + opts.screenName, + opts.color, + ); + 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); + 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: 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. */ +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); +} + +/** + * 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 + * 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/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.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..24180b5e5 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'; @@ -66,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(...)` @@ -150,6 +168,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 +856,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 +1536,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 +1551,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 @@ -1529,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 @@ -1639,6 +1702,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 +1758,50 @@ 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. + * + * 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 +1827,8 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS getSyncDiagnostics, createNewProject, getActorId, + switchActor, + flush, }; } diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index a8066d86f..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, @@ -87,7 +88,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 diff --git a/ts-packages/quarto-sync-client/src/test-hub.ts b/ts-packages/quarto-sync-client/src/test-hub.ts index 1dbceae20..f0c87b827 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 @@ -57,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)); @@ -95,12 +119,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; @@ -116,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) 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;