diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 417f09d6..02b06eff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # The anchor WASM equivalence test invokes the native Rust example. + # On Linux, the attn crate links its desktop target dependencies even + # though the example itself only exercises the canonical parser. + - name: Install native test dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev + working-directory: . + - name: Setup Node uses: actions/setup-node@v4 with: diff --git a/planning/web-authoring/parity-matrix.md b/planning/web-authoring/parity-matrix.md new file mode 100644 index 00000000..c7aebc78 --- /dev/null +++ b/planning/web-authoring/parity-matrix.md @@ -0,0 +1,46 @@ +# Browser/native release parity matrix + +Date: 2026-07-11 (attn-7xl.7.1) + +This matrix records the repeatable evidence for the accountless browser +workspace release. `PASS` cells have an automated command. `DEVICE GATE` cells +require current physical Apple hardware and remain tracked by attn-7xl.2.9 and +attn-7xl.7.6; they are not silently treated as simulated passes. + +## Owner/reviewer and transport matrix + +| Owner | Reviewer | Stable/live path | Mailbox/offline path | Direct path | Result and command | +|---|---|---|---|---|---| +| Browser | Browser | Production durable-share resolver joins the browser-owned V3 RoomDO | Browser owner validates reviewer registration/grants/events, forwards exact ciphertext, ACKs ShareDO, and seals cursor | Browser transport/collab controller suites exercise direct-first delivery | **PASS** — `npm --prefix web run test:share-owner:live`, `npm --prefix web test` | +| Browser | Native | Native Rust resolves the selected stable bearer, authenticates the manifest/sealed bundle, and registers in the browser-owned room | Same room/mailbox protocol; owner-side browser drain is covered with exact frozen browser submissions | V3 room capabilities feed the existing native transport | **PASS** — `npm --prefix web run test:share-owner:live`, `cargo test --lib share_lifecycle` | +| Native | Browser | Production browser resolves retained ciphertext and upgrades into the actual native RoomDO | Comment survives destroyed RoomDO; owner restart recreates the deterministic epoch, imports/forwards/ACKs, and the existing browser upgrades without reload | Browser and native daemons negotiate a real DataChannel | **PASS** — `scripts/test-share-e2e.sh`, `scripts/test-webrtc-live-e2e.sh` | +| Native | Native | Existing canonical share/join path | Existing relay mailbox and restart paths | Real two-daemon DataChannel with comment, co-edit, and tracked suggestion | **PASS** — `scripts/test-webrtc-live-e2e.sh`, Rust review suites | +| Multiple browser tabs | Browser-owned workspace | One workspace lease/fencing token owns mutations; passive tabs are read-only until takeover | Pending ciphertext resumes byte-identically after reload/tab handoff | Per-file controller generation rejects stale deliveries | **PASS** — `browser-workspace-lease`, `browser-workspace-sharing`, `browser-owner-workspace-runtime`, and `browser-owner-authority` suites under `npm --prefix web test` | + +## Content, action, and recovery matrix + +| Cell | Required behavior | Result and evidence | +|---|---|---| +| Current file / selected entries / whole workspace | One canonical normalized manifest; stable FileIds; pointer publishes last | **PASS** — browser snapshot publisher/workspace manifest suites | +| Nested Markdown | Multiple files remain distinct and publish in one room; new files publish live | **PASS** — `scripts/test-folder-share-e2e.sh` (3/3) plus browser publisher tests | +| Raster and arbitrary binary assets | Raw bytes hash/length/media type are manifest-bound; binary is base64url inside E2EE snapshot; active/unknown content is not executed | **PASS** — browser snapshot publisher, session hydration, manifest, shell, and import/export suites | +| Missing/stale paths and references | Stale scope paths fail before network; deleted/renamed entries reconcile; missing assets remain inert | **PASS** — browser workspace sharing/store/manifest suites and hosted authoring Playwright | +| Mailbox and R2 thresholds | Small sealed snapshot uses mailbox; large sealed body uses authenticated same-origin R2 capability; swapped body/AAD fails | **PASS** — browser snapshot publisher/R2 suites; relay remains content-blind | +| Comments, replies, resolution | Signed events render, retry byte-identically, and owner terminal actions persist before broadcast | **PASS** — browser session, owner authority, review actions, selectors/margin suites; WebRTC live comment gate | +| Suggestions and apply | Comment tier cannot escalate; suggest tier is room-bound; accept/reject/apply uses fenced atomic receipts and survives autosave | **PASS** — browser session/authority/review-action suites; WebRTC tracked-suggestion gate | +| Co-editing | Owner is the single authority; checkpoint persists before commit/broadcast; concurrent editors converge | **PASS** — collab authority/controller/session suites; WebRTC live co-typing | +| Owner offline / reconnect | Stable reviewer can author offline; owner recreates same epoch, forwards once, ACKs after durability, and renews 90-day ShareDO | **PASS** — durable-share real stack and browser coordinator recovery tests | +| Reload and process restart | Sealed capability, pending exact ciphertext, rollback floor, outbox, and owner checkpoint resume without duplicate apply | **PASS** — browser storage/share/session/authority suites and durable-share restart E2E | +| Export/import and crypto-erasure | ZIP/backup preserves nested bytes; stop/clear removes key material before records; revoked links fail | **PASS** — import/export, storage, workspace crypto/share suites and live revoke gates | +| Invalid/malicious invite or mailbox input | Noncanonical URLs, fragments, tier mismatch, wrong grant, forged signer, ciphertext swap, rollback, and cursor gaps fail closed without secret echo | **PASS** — browser invite/share/session/push/mailbox suites and Rust resolver/lifecycle suites | + +## Route, viewport, and deployment matrix + +| Environment | Result | +|---|---| +| Local Chromium hosted routes/share UI | **PASS** — focused Share sheet 7/7 and full web unit suite 78 files | +| Cloudflare staging | **PASS** — worker `fb0ebc57-8e38-4b12-8f97-e1dc79f45291`; full Playwright routes/authoring/offline/share/mobile/a11y 64/64 | +| Chromium + WebKit storage/reader suite | **PASS** — prior Phase 01/02 validation records; OPFS vs honest fallback and 320–430 px reader-first layouts | +| Current iPhone Safari normal/private/Home Screen | **DEVICE GATE** — attn-7xl.2.9 / ios-device-protocol.md | +| Current iPad Safari and Split View, 200% text, VoiceOver, dynamic chrome, Web Share/native handoff | **DEVICE GATE** — attn-7xl.7.6 / ios-device-protocol.md | +| Production `attn.sh` origin and rollback rehearsal | **HUMAN APPROVAL GATE** — attn-7xl.7.4 and .7.6; production was not changed during this matrix | diff --git a/planning/web-authoring/security-review.md b/planning/web-authoring/security-review.md new file mode 100644 index 00000000..f020eb0c --- /dev/null +++ b/planning/web-authoring/security-review.md @@ -0,0 +1,41 @@ +# Browser workspace security review + +Status: reviewed on 2026-07-11. No open critical or high-severity findings were identified in the browser-owned sharing path. Production cutover and physical iOS validation remain separate human gates. + +## Scope and trust boundaries + +The review covers browser workspace storage, import, rendering, service-worker behavior, browser-owned relay rooms, stable share capabilities, encrypted snapshot publication, reviewer admission, offline mailbox draining, and native/browser interoperability. + +Workspace content is plaintext inside the trusted browser runtime while it is open. At rest, workspace records and share-owner state are sealed with non-extractable browser keys; large payloads may live in OPFS under the same sealed-record model. This protects persisted data and supports cryptographic erasure, but it does not protect against a compromised same-origin runtime, malicious browser extension, compromised device, or an attacker controlling an unlocked session. + +Relay and hosting operators can observe traffic metadata such as opaque identifiers, timing, size, and network addresses. They do not receive workspace plaintext or invite secrets: stable invite capability material remains in the URL fragment, and published snapshots, events, and mailbox items are encrypted and authenticated in the browser. + +## Controls reviewed + +- Hosted responses enforce a restrictive CSP, same-origin opener/resource policies, no-referrer, HSTS, MIME sniffing protection, frame denial, and a narrow permissions policy. HTML and service-worker entry points are non-cacheable; hashed static assets are immutable. +- The service worker caches only application shells and static assets. It excludes cross-origin requests, query-bearing requests, and user workspace content. Push payloads contain no document plaintext; notification details are decrypted locally. +- Markdown parsing disables embedded HTML. Hosted HTML previews run in an iframe without sandbox capabilities and with a no-referrer policy. Mermaid uses strict security mode, and KaTeX keeps its default untrusted-input behavior. +- Relay owner requests omit credentials, disable caching, and use authenticated canonical request material. Room ownership, reviewer grants, envelopes, acknowledgements, revocation, and teardown are signed and verified using the native protocol shapes. +- Reviewer mailbox items are validated before forwarding: cursor continuity, bundle identity, device registration, owner grant, permitted event type, AEAD authentication, derived IDs, and Ed25519 signatures are checked. The source item is acknowledged only after durable forwarding. +- ZIP imports normalize and validate paths before expansion and enforce archive-size, entry-count, per-entry, and total-expanded-size limits. Traversal and expansion-budget cases have regression tests. +- Local and session storage contain presentation preferences and transient UI state only. Workspace roots, stable owner secrets, and invite capability keys are not persisted there. +- Production dependency audit for the web and relay packages reported no vulnerabilities at high severity or above. The live relay harness also scans logs for workspace plaintext and stable owner secrets. + +## Findings + +### Fixed: unbounded ZIP expansion + +Imported ZIP metadata previously had no explicit expansion budget. The importer now rejects archives over 64 MiB, more than 1,024 entries, individual expanded files over 64 MiB, or aggregate expanded content over 128 MiB. Paths are rejected before decompression when they are absolute, traverse upward, contain NUL bytes, or otherwise normalize outside the archive root. + +### Accepted limitations + +- Trusted Types are not enforced because support and compatibility across the target iOS/Safari matrix are incomplete. HTML-producing sinks are instead limited to audited renderers and sandboxed preview surfaces under CSP. +- A local notification may reveal the locally decrypted document name or activity count on the device lock screen according to the user's OS notification settings. +- Possession of a stable invite URL fragment grants the advertised reviewer capability until the owner revokes or tears down the share. This is intentional bearer-capability behavior and is surfaced as such in the Share UI. +- A Rust advisory scan could not be run because `cargo-audit` is not installed in the validation environment. Cargo compilation and the protocol/interoperability test suites remain required quality gates. + +## Evidence + +The feature validation record is in `validation-03.md`, and the native/browser coverage map is in `parity-matrix.md`. Automated evidence includes browser unit tests, hosted-route boundary tests, staging Playwright coverage, relay integration tests, Rust share-lifecycle tests, native-to-browser and browser-to-native live relay harnesses, folder sharing, WebRTC, revocation, teardown, and ciphertext/log leakage checks. + +The remaining gates do not change this review result: physical iOS Safari verification requires real devices, and production routing requires explicit human approval. diff --git a/planning/web-authoring/validation-03.md b/planning/web-authoring/validation-03.md new file mode 100644 index 00000000..672d47f1 --- /dev/null +++ b/planning/web-authoring/validation-03.md @@ -0,0 +1,18 @@ +# Phase 03 validation record — browser-owned encrypted sharing + +Date: 2026-07-11 (attn-7xl.4.7) + +Reproducible from the repository root unless noted. + +| Gate (03-browser-sharing.md §Validation) | Command | Result | +|---|---|---| +| Svelte/TypeScript and production bundles | `cd web && npm run check && npm run build:browser && npm run check:route-bundles` | 0 errors, 0 warnings; browser + service-worker builds pass; landing/app statically reach only 8/12 files and do not preload editor or crypto chunks | +| Browser protocol/storage/authority suites | `cd web && npm test` | 78 test files, 0 failures — includes native-compatible v3 bootstrap, canonical snapshot/manifest publication with Markdown and inert binary assets, fenced owner authority, stable ShareDO ownership, renewal/recreate, offline mailbox preflight/forward/ACK, rollback, stop/recreate, and tier isolation | +| Rust stable-link resolver and owner lifecycle | `cargo check && cargo test --lib share_lifecycle` | native resolver authenticates the selected ShareDO record, manifest, sealed tier bundle, room pointer, and v3 capability fragment; 14/14 lifecycle tests pass | +| Browser owner → browser/native reviewers against real relay | `cd web && npm run test:share-owner:live` | real Wrangler RoomDO + ShareDO: browser creates/owns v3 room, uploads retained ciphertext, flips stable pointer, authenticates mailbox query/ACK, the production browser reviewer and native Rust both resolve the browser comment bearer and join the same room, then owner revoke/teardown succeeds; relay log scan contains neither snapshot plaintext nor stable owner secret | +| Native owner ↔ production browser, offline mailbox, restart, watch upgrade, revoke | `scripts/test-share-e2e.sh` | full real-stack lifecycle passes: browser decrypts retained native snapshot and joins live; offline browser comment survives missing RoomDO; restarted native owner recreates the deterministic epoch, imports/forwards/ACKs, browser upgrades without reload, and revoke terminates the session | +| Multi-file publication | `scripts/test-folder-share-e2e.sh` | 3/3: two nested Markdown files publish in one room, non-Markdown input is excluded by this legacy native gate, and a newly-created Markdown file publishes live. Browser publisher unit coverage separately proves canonical workspace manifests and inert binary assets without UTF-8 coercion | +| Live direct transport and owner/reviewer editing | `scripts/test-webrtc-live-e2e.sh` | 12/12: authenticated daemon join, mutual presence, both badges `live_direct`, comment delivery over DataChannel, convergent co-typing, reviewer edit arrives as a tracked suggestion, accepted-only autosave, and suggestion survives reseed risk | +| Share-sheet UX, mobile, and accessibility | `cd web && npx playwright test e2e/hosted-share-sheet.spec.ts --config playwright.routes.config.ts` | 7/7: independent view/comment/suggest bearers, matching browser/native/CLI tier forms, Web Share + clipboard fallback, axe clean, ≥44 px mobile permission targets, and keyboard-focused destructive confirmation | +| Cloudflare staging | `cd web && npm run deploy:staging` then `ATTN_ROUTES_BASE_URL=https://staging.attn.sh npx playwright test --config playwright.routes.config.ts` | deployed worker version `fb0ebc57-8e38-4b12-8f97-e1dc79f45291`; staging asset/relay-origin verification passes; full routes/authoring/offline/share/mobile/a11y matrix 64/64 | +| Production `attn.sh` cutover and real iPhone/iPad matrix | — | **Not performed** — production remains explicitly human-gated in attn-7xl.7; current-device Safari validation remains tracked with attn-7xl.2.9 | diff --git a/relay/test/integration/share-lifecycle-e2e.test.ts b/relay/test/integration/share-lifecycle-e2e.test.ts index 9888905d..11225cf9 100644 --- a/relay/test/integration/share-lifecycle-e2e.test.ts +++ b/relay/test/integration/share-lifecycle-e2e.test.ts @@ -425,5 +425,5 @@ describe("durable share v3 real-stack lifecycle", () => { expect((await SELF.fetch(`${shareUrl}/snapshots/readme`)).status).toBe(404); expect((await SELF.fetch(mailboxUrl)).status).toBe(404); expect((await env.RELAY_BLOBS.list({ prefix: shareArtifactPrefix(shareId) })).objects).toHaveLength(0); - }); + }, 30_000); }); diff --git a/scripts/test-folder-share-e2e.sh b/scripts/test-folder-share-e2e.sh index 8945dca4..c1fcb864 100755 --- a/scripts/test-folder-share-e2e.sh +++ b/scripts/test-folder-share-e2e.sh @@ -45,7 +45,9 @@ if [ -n "${ATTN_EXTERNAL_RELAY:-}" ]; then else [ -d "$PROJECT_DIR/relay/node_modules" ] || (cd relay && npm ci >/dev/null) log "Starting relay on :$RELAY_PORT" - ( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$! + ( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" \ + --var QUOTA_ALLOW_UNATTRIBUTED_CREATES:true \ + --var BLOB_CAP_SIGNING_KEY:folder-share-e2e-blob-key-material-32 ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$! deadline=$(( $(date +%s) + 60 )); while [ "$(date +%s)" -lt "$deadline" ]; do curl -fsS "$RELAY_URL/health" >/dev/null 2>&1 && break; kill -0 "$RELAY_PID" 2>/dev/null || { log "relay died"; exit 1; }; sleep 0.3; done fi log "Relay healthy" diff --git a/scripts/test-webrtc-live-e2e.sh b/scripts/test-webrtc-live-e2e.sh index ae832a5e..1740cf61 100755 --- a/scripts/test-webrtc-live-e2e.sh +++ b/scripts/test-webrtc-live-e2e.sh @@ -58,7 +58,9 @@ if [ -n "${ATTN_EXTERNAL_RELAY:-}" ]; then else [ -d "$PROJECT_DIR/relay/node_modules" ] || (cd relay && npm ci >/dev/null) log "Starting relay on :$RELAY_PORT" - ( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" ) >"$RELAY_LOG" 2>&1 & + ( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" \ + --var QUOTA_ALLOW_UNATTRIBUTED_CREATES:true \ + --var BLOB_CAP_SIGNING_KEY:webrtc-live-e2e-blob-key-material-32 ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$! deadline=$(( $(date +%s) + 60 )) while [ "$(date +%s)" -lt "$deadline" ]; do curl -fsS "$RELAY_URL/health" >/dev/null 2>&1 && break; kill -0 "$RELAY_PID" 2>/dev/null || { log "relay died"; tail -20 "$RELAY_LOG"; exit 1; }; sleep 0.3; done @@ -80,15 +82,25 @@ wait_ready attn_owner '[data-slot=share-invite-url]' 20000 || { log "no invite f INVITE=""; deadline=$(( $(date +%s) + 15 )) while [ "$(date +%s)" -lt "$deadline" ]; do INVITE="$(attn_owner --eval "document.querySelector('[data-slot=share-invite-url]')?.value||''" 2>/dev/null | tr -d '"\\' | tr -d '\r\n')" - case "$INVITE" in attn://review/*) break;; esac; sleep 0.3 + case "$INVITE" in attn://review/*|https://attn.sh/review/*|https://staging.attn.sh/review/*) break;; esac; sleep 0.3 done -case "$INVITE" in attn://review/*) ok "owner minted invite";; *) bad "no invite ('$INVITE')"; exit 1;; esac +case "$INVITE" in attn://review/*|https://attn.sh/review/*|https://staging.attn.sh/review/*) ok "owner minted invite";; *) bad "no invite ('$INVITE')"; exit 1;; esac -log "Reviewer joins (review_join IPC)" +log "Reviewer joins through the authenticated daemon socket" # Pre-set the reviewer's name too so the post-join onboarding prompt doesn't # overlay the editor during the co-typing/suggesting assertions below. attn_rv --eval "window.__attn_user_profile__ && window.__attn_user_profile__.save('Reviewer');'x'" >/dev/null 2>&1 || true -attn_rv --eval "window.ipc && window.ipc.postMessage(JSON.stringify({type:'review_join',invite:'$INVITE'}));'x'" >/dev/null 2>&1 && ok "reviewer join dispatched" || bad "reviewer join failed" +JOIN_INVITE="$INVITE" +case "$JOIN_INVITE" in + https://attn.sh/review/*) JOIN_INVITE="attn://review/${JOIN_INVITE#https://attn.sh/review/}" ;; + https://staging.attn.sh/review/*) JOIN_INVITE="attn://review/${JOIN_INVITE#https://staging.attn.sh/review/}" ;; +esac +JOIN_ERROR="" +if JOIN_ERROR="$(attn_rv review join "$JOIN_INVITE" 2>&1)"; then + ok "reviewer join dispatched" +else + bad "reviewer join failed: ${JOIN_ERROR%%$'\n'*}" +fi # Presence converges (each sees 1 peer). peer_count() { "$1" --query '[data-slot=peer-chip]' 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin).get("count",0))' 2>/dev/null || echo 0; } diff --git a/src/daemon.rs b/src/daemon.rs index 59308e02..bf37fdc1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -881,7 +881,11 @@ fn parse_room_id(raw: String) -> crate::review::ids::RoomId { /// `Bootstrapper::join`, which subscribes the daemon's own window to the /// room (a windowed join — NOT the headless `--as-agent` path). pub fn log_review_join_intent(invite: &str) { - tracing::info!("review join — routing invite to ReviewManager: {invite}"); + tracing::info!("{}", review_join_log_message(invite)); +} + +fn review_join_log_message(_invite: &str) -> &'static str { + "review join — routing redacted invite to ReviewManager" } /// Dispatch a `ReviewJoin` from the in-process custom-protocol handler. @@ -904,9 +908,9 @@ pub fn dispatch_review_join(invite: &str, review_manager: Option<&Arc>, @@ -923,12 +927,18 @@ pub fn dispatch_share_open_intent( ), ); }; - manager.submit(command); - Err( - crate::review::share_lifecycle::ShareLifecycleError::NotImplemented( - "durable share resolver adapter is not wired".into(), - ), - ) + if let ReviewCommand::OpenDurableShare { + share_id, + link_secret, + } = &command + { + manager + .open_durable_share(share_id, link_secret) + .map_err(|error| { + crate::review::share_lifecycle::ShareLifecycleError::Relay(error.to_string()) + })?; + } + Ok(()) } /// Start listening on the unix socket. Spawns a thread that accepts connections @@ -1447,6 +1457,15 @@ mod tests { (mgr, rx, tmp) } + #[test] + fn review_join_log_message_never_contains_invite_secret() { + let invite = "attn://review/room-canary#key=SECRET-CANARY"; + let message = review_join_log_message(invite); + assert!(!message.contains(invite)); + assert!(!message.contains("SECRET-CANARY")); + assert!(!message.contains("#key=")); + } + #[test] fn submit_review_socket_command_routes_to_manager() { // The daemon-socket side mirrors the webview-side IPC dispatch: @@ -1513,12 +1532,14 @@ mod tests { let result = dispatch_share_open_intent(&invite, Some(&manager)); assert!(matches!( result, - Err(crate::review::share_lifecycle::ShareLifecycleError::NotImplemented(_)) + Err(crate::review::share_lifecycle::ShareLifecycleError::Relay( + _ + )) )); - let open = rx.try_recv().expect("open update"); - let rendered = format!("{open:?}"); - assert!(rendered.contains("AAECAwQFBgcICQoLDA0ODw")); - assert!(!rendered.contains(&encoded_secret)); + assert!( + rx.try_recv().is_err(), + "failed routing emits no secret-bearing update" + ); let absent = dispatch_share_open_intent(&invite, None); assert!(matches!( diff --git a/src/main.rs b/src/main.rs index eed9a9d5..d8e2213e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -685,7 +685,7 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { ); let (status, body) = share_open_response(dispatched.is_ok()); if dispatched.is_err() { - tracing::warn!(share_id = %invite.share_id, "durable share open is not implemented"); + tracing::warn!(share_id = %invite.share_id, "durable share open failed"); } return wry::http::Response::builder() .status(status) @@ -1163,7 +1163,7 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { ) .is_err() { - tracing::warn!(share_id = %invite.share_id, "durable share open is not implemented"); + tracing::warn!(share_id = %invite.share_id, "durable share open failed"); } platform::activate_app(); window.set_visible(true); @@ -1897,6 +1897,7 @@ fn parse_review_invite(uri: &str) -> Option { /// Notification deep links contain no room secret and therefore focus only a /// room already present in the local store; they never invoke Join. +#[cfg(any(target_os = "macos", test))] fn notification_room_id_from_deep_link(uri: &str) -> Option { review::notifications::room_id_from_deep_link(uri) } @@ -1905,6 +1906,7 @@ fn notification_room_id_from_deep_link(uri: &str) -> Option { /// hydrated, then select it through the same production store bridge used by /// an already-open window. The room id is JSON encoded before crossing into /// JavaScript so an untrusted URI segment cannot become executable source. +#[cfg(any(target_os = "macos", test))] fn notification_focus_script(room_id: &str) -> String { review::notifications::focus_script(room_id) } diff --git a/src/review/bootstrap.rs b/src/review/bootstrap.rs index a61780d1..4a4179ad 100644 --- a/src/review/bootstrap.rs +++ b/src/review/bootstrap.rs @@ -852,7 +852,7 @@ fn build_browser_invite_url_from_base( pub fn parse_invite(invite: &str) -> Result { let rest = invite .strip_prefix("attn://review/") - .ok_or_else(|| BootstrapError::InviteParse(format!("missing prefix: {invite}")))?; + .ok_or_else(|| BootstrapError::InviteParse("missing attn review prefix".into()))?; let (room_id_str, fragment) = rest .split_once('#') .ok_or_else(|| BootstrapError::InviteParse("missing key fragment".into()))?; @@ -4801,10 +4801,13 @@ mod tests { #[test] fn parse_invite_rejects_missing_prefix() { - let err = parse_invite("not-an-invite").expect_err("malformed"); + let raw = "https://attn.sh/review/room#key=SECRET-CANARY"; + let err = parse_invite(raw).expect_err("malformed"); match err { BootstrapError::InviteParse(msg) => { - assert!(msg.contains("missing prefix"), "got: {msg}"); + assert!(msg.contains("missing attn review prefix"), "got: {msg}"); + assert!(!msg.contains(raw), "input leaked: {msg}"); + assert!(!msg.contains("SECRET-CANARY"), "secret leaked: {msg}"); } other => panic!("expected InviteParse, got {other:?}"), } diff --git a/src/review/manager.rs b/src/review/manager.rs index 91170dbb..d58d839e 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -823,6 +823,23 @@ impl ReviewManager { // Bootstrap pipeline owns Share + Join when wired in. Everything else // still goes through `stub_update_for` (filled in by follow-up issues). match (&cmd, self.bootstrap.as_ref(), self.runtime.as_ref()) { + ( + ReviewCommand::OpenDurableShare { + share_id, + link_secret, + }, + Some(_), + Some(_), + ) => { + if let Err(error) = self.open_durable_share(share_id, link_secret) { + (self.update_tx)(ReviewUpdate::Error { + room_id: None, + code: "ATTN_DURABLE_SHARE_OPEN".into(), + message: error.to_string(), + }); + } + return; + } (ReviewCommand::CreateDurableShare { path }, _, Some(runtime)) => { let result = self .durable_shares @@ -1206,6 +1223,34 @@ impl ReviewManager { Ok(links) } + /// Resolve a stable public link and hand its exact tier-scoped v3 room + /// invite to the existing native Join pipeline. + pub fn open_durable_share( + &self, + share_id: &str, + link_secret: &crate::review::share_lifecycle::ShareLinkSecret, + ) -> anyhow::Result<()> { + let bootstrap = self + .bootstrap + .as_ref() + .ok_or_else(|| anyhow::anyhow!("review bootstrap unavailable"))?; + let runtime = self + .runtime + .as_ref() + .ok_or_else(|| anyhow::anyhow!("review runtime unavailable"))?; + let invite = runtime + .block_on( + crate::review::share_lifecycle::resolve_public_share_to_room_invite( + &bootstrap.config().relay_url, + share_id, + link_secret, + ), + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + self.submit(ReviewCommand::Join { invite }); + Ok(()) + } + pub fn emit_durable_command_result( &self, command: &ReviewCommand, @@ -3522,9 +3567,9 @@ fn stub_update_for(cmd: &ReviewCommand) -> ReviewUpdate { }, // TODO(attn-nnj.3b): parse invite, open transport, fetch snapshot, emit // RoomStatus + SnapshotCreated as data arrives. - ReviewCommand::Join { invite } => ReviewUpdate::RoomStatusChanged { + ReviewCommand::Join { invite: _ } => ReviewUpdate::RoomStatusChanged { room_id: stub_room_id(), - status: format!("Pending join — not yet implemented (invite={invite})"), + status: "Pending join — invite accepted for processing".to_string(), }, // Pull / Stop / Inbox are handled for real in `submit` (they drive the // per-room runtime registries) and always return before reaching here. @@ -5948,7 +5993,7 @@ mod tests { } #[test] - fn submit_join_emits_room_status_changed_with_invite() { + fn submit_join_status_never_exposes_invite_fragment() { let (mgr, rx, _tmp) = make_manager(); let invite = "attn://review/abc#key=xyz".to_string(); mgr.submit(ReviewCommand::Join { @@ -5957,7 +6002,9 @@ mod tests { let update = rx.try_recv().expect("expected one update"); match update { ReviewUpdate::RoomStatusChanged { status, .. } => { - assert!(status.contains(&invite)); + assert!(status.contains("Pending join")); + assert!(!status.contains(&invite)); + assert!(!status.contains("key=xyz")); } other => panic!("expected RoomStatusChanged, got {other:?}"), } diff --git a/src/review/share_lifecycle.rs b/src/review/share_lifecycle.rs index 92be5719..847b2bf4 100644 --- a/src/review/share_lifecycle.rs +++ b/src/review/share_lifecycle.rs @@ -19,11 +19,13 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest as _, Sha256}; use zeroize::{Zeroize, Zeroizing}; use crate::review::bootstrap::{ - BOOTSTRAP_POW_DIFFICULTY, BOOTSTRAP_POW_TTL_MS, Bootstrapper, DeviceIdentity, - admission_header_value_v3_with_query, owner_sig_header_value_with_query, + BOOTSTRAP_POW_DIFFICULTY, BOOTSTRAP_POW_TTL_MS, Bootstrapper, DeviceIdentity, InviteTierV3, + admission_header_value_v3_with_query, build_invite_fragment_v3, + owner_sig_header_value_with_query, }; use crate::review::crypto::kdf::{ ShareLinkTier, derive_room_id_v3, derive_room_key_tree_v3, derive_share_epoch_room_secret, @@ -32,8 +34,8 @@ use crate::review::crypto::kdf::{ use crate::review::crypto::pow::TokenPool; use crate::review::ids::RoomId; use crate::review::share::{ - ShareCapabilityBundle, build_browser_share_invite, build_native_share_invite, - seal_capability_bundle_with_nonce, + ShareBundleContext, ShareCapabilityBundle, build_browser_share_invite, + build_native_share_invite, open_capability_bundle, seal_capability_bundle_with_nonce, }; const RECORD_VERSION: u32 = 3; @@ -982,6 +984,139 @@ pub struct SelectedShareBundle { pub sealed_bundle: String, } +/// Resolve a stable public bearer into the currently authenticated ordinary +/// v3 room invite. The returned URL contains only the tier-scoped room leaves +/// from the sealed bundle; the ShareDO admission and bundle keys are dropped. +/// +/// Native Join then reuses its existing v3 directory/grant verification and +/// transport path. A missing current room remains a normal Join failure; the +/// retained-snapshot offline adapter is intentionally a separate concern. +pub async fn resolve_public_share_to_room_invite( + relay_url: &str, + share_id: &str, + link_secret: &ShareLinkSecret, +) -> Result { + let http = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| ShareLifecycleError::Relay(error.to_string()))?; + let path = format!("/v3/shares/{share_id}"); + // Bundle id, bundle key, and read admission are expanded directly from the + // tier-specific URL bearer and are identical regardless of which enum is + // passed here. The tier itself is authenticated inside the selected relay + // record and sealed bundle; it cannot be inferred by probing because it is + // intentionally absent from the public URL. + let probe = crate::review::crypto::kdf::expand_share_link_keys( + link_secret.expose(), + ShareLinkTier::View, + ); + let response = http + .get(format!("{}{}", relay_url.trim_end_matches('/'), path)) + .header("Attn-Share-Bundle", &probe.bundle_id) + .header( + "Attn-Admission", + admission_header_value_v3_with_query( + probe.read_admission_key.as_bytes(), + "read", + "GET", + &path, + &[], + &[], + ), + ) + .send() + .await + .map_err(|error| ShareLifecycleError::Relay(error.to_string()))?; + let record: ShareRelayRecord = HttpShareRelayClient::decode_json(response).await?; + let selected_bundle = record.bundle.as_ref().ok_or_else(|| { + ShareLifecycleError::Relay("share response omitted the selected bundle".into()) + })?; + let tier = match selected_bundle.tier { + ShareTier::View => ShareLinkTier::View, + ShareTier::Comment => ShareLinkTier::Comment, + ShareTier::Suggest => ShareLinkTier::Suggest, + }; + let keys = crate::review::crypto::kdf::expand_share_link_keys(link_secret.expose(), tier); + if selected_bundle.bundle_id != keys.bundle_id { + return Err(ShareLifecycleError::Relay( + "share response selected a mismatched bundle".into(), + )); + } + let manifest_bytes = crate::review::crypto::canonical::to_canonical_bytes(&record.snapshots) + .map_err(|error| ShareLifecycleError::Invalid(format!("share manifest: {error}")))?; + let manifest_digest = URL_SAFE_NO_PAD.encode(Sha256::digest(&manifest_bytes)); + if manifest_digest != record.manifest_digest { + return Err(ShareLifecycleError::Relay( + "share manifest digest failed authentication".into(), + )); + } + let opened = open_capability_bundle( + keys.bundle_key.as_bytes(), + &ShareBundleContext { + bundle_id: &keys.bundle_id, + share_id, + epoch: record.epoch, + revision: record.revision, + manifest_digest: &record.manifest_digest, + tier, + }, + &selected_bundle.sealed_bundle, + ) + .map_err(ShareLifecycleError::Invalid)?; + let current_room = record + .current_room_id + .as_deref() + .ok_or_else(|| ShareLifecycleError::NotFound("stable share has no active room".into()))?; + if opened.room_id != current_room { + return Err(ShareLifecycleError::Relay( + "sealed bundle room does not match the active share pointer".into(), + )); + } + let read: [u8; 32] = URL_SAFE_NO_PAD + .decode(&opened.read_capability_key) + .map_err(|_| ShareLifecycleError::Invalid("bundle read capability is invalid".into()))? + .try_into() + .map_err(|_| { + ShareLifecycleError::Invalid("bundle read capability length is invalid".into()) + })?; + let write = opened + .write_admission_key + .as_deref() + .map(|value| { + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| { + ShareLifecycleError::Invalid("bundle write capability is invalid".into()) + })? + .try_into() + .map_err(|_| { + ShareLifecycleError::Invalid("bundle write capability length is invalid".into()) + }) + }) + .transpose()?; + let grant = opened + .grant_signature + .as_deref() + .map(|value| { + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| ShareLifecycleError::Invalid("bundle grant is invalid".into()))? + .try_into() + .map_err(|_| ShareLifecycleError::Invalid("bundle grant length is invalid".into())) + }) + .transpose()?; + let invite_tier = match tier { + ShareLinkTier::View => InviteTierV3::View, + ShareLinkTier::Comment => InviteTierV3::Comment, + ShareLinkTier::Suggest => InviteTierV3::Suggest, + }; + let fragment = build_invite_fragment_v3(invite_tier, &read, write.as_ref(), grant.as_ref()) + .map_err(|error| ShareLifecycleError::Invalid(error.to_string()))?; + Ok(format!("attn://review/{current_room}{fragment}")) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ShareMailboxItem { @@ -3768,6 +3903,79 @@ mod tests { )); } + #[tokio::test] + async fn public_share_resolver_authenticates_comment_bundle_and_builds_v3_invite() { + let server = MockServer::start().await; + let owner_root = [0x42; 32]; + let comment = derive_share_link_keys(&owner_root, ShareLinkTier::Comment); + let room_id = URL_SAFE_NO_PAD.encode([0x11; 16]); + let read = [0x22; 32]; + let write = [0x33; 32]; + let grant = [0x44; 64]; + let bundle = ShareCapabilityBundle { + v: 3, + purpose: "attn share capability bundle v3".into(), + bundle_id: comment.bundle_id.clone(), + owner_signing_key: URL_SAFE_NO_PAD.encode([0x55; 32]), + share_id: SHARE_ID.into(), + epoch: 7, + revision: 9, + manifest_digest: EMPTY_MANIFEST_DIGEST.into(), + tier: ShareLinkTier::Comment, + room_id: room_id.clone(), + read_capability_key: URL_SAFE_NO_PAD.encode(read), + write_admission_key: Some(URL_SAFE_NO_PAD.encode(write)), + grant_signature: Some(URL_SAFE_NO_PAD.encode(grant)), + }; + let sealed_bundle = seal_capability_bundle_with_nonce( + comment.bundle_key.as_bytes(), + &comment.bundle_id, + &bundle, + &[0x66; 24], + ) + .expect("seal comment bundle"); + + Mock::given(method("GET")) + .and(path(format!("/v3/shares/{SHARE_ID}"))) + .and(header("Attn-Share-Bundle", comment.bundle_id.as_str())) + .and(header_exists("Attn-Admission")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "v": 3, + "shareId": SHARE_ID, + "ownerSigningKey": URL_SAFE_NO_PAD.encode([0x55; 32]), + "epoch": 7, + "revision": 9, + "currentRoomId": room_id, + "snapshots": [], + "placeholders": [], + "manifestDigest": EMPTY_MANIFEST_DIGEST, + "bundle": { + "bundleId": comment.bundle_id, + "tier": "comment", + "sealedBundle": sealed_bundle + }, + "updatedAt": 1, + "expiresAt": 2, + "mailbox": { "count": 0, "bytes": 0, "latestSeq": 0 }, + "features": { "push": false } + }))) + .expect(1) + .mount(&server) + .await; + + let invite = resolve_public_share_to_room_invite( + &server.uri(), + SHARE_ID, + &ShareLinkSecret::new(*comment.link_secret.as_bytes()), + ) + .await + .expect("resolve comment link"); + let fragment = + build_invite_fragment_v3(InviteTierV3::Comment, &read, Some(&write), Some(&grant)) + .expect("comment fragment"); + assert_eq!(invite, format!("attn://review/{room_id}{fragment}")); + } + #[tokio::test] async fn http_client_scopes_bundle_read_and_owner_revoke_headers() { let server = MockServer::start().await; diff --git a/tests/durable_share_native_e2e.rs b/tests/durable_share_native_e2e.rs index 7aafc25f..ebdfb74a 100644 --- a/tests/durable_share_native_e2e.rs +++ b/tests/durable_share_native_e2e.rs @@ -173,6 +173,54 @@ async fn destroy_actual_room(store: &DurableShareStore, state: &HarnessState) { ); } +#[tokio::test] +#[ignore = "orchestrated by the browser-owner live relay harness"] +async fn browser_owned_share_native_reviewer_real_stack() { + let relay_url = required("ATTN_RELAY_URL"); + let share_id = required("ATTN_BROWSER_OWNER_SHARE_ID"); + let expected_room_id = required("ATTN_BROWSER_OWNER_ROOM_ID"); + let link_secret: [u8; 32] = URL_SAFE_NO_PAD + .decode(required("ATTN_BROWSER_OWNER_LINK_SECRET")) + .expect("decode browser-owned stable bearer") + .try_into() + .expect("32-byte browser-owned stable bearer"); + let invite = attn::review::share_lifecycle::resolve_public_share_to_room_invite( + &relay_url, + &share_id, + &attn::review::share_lifecycle::ShareLinkSecret::new(link_secret), + ) + .await + .expect("native resolves browser-owned stable share"); + assert!( + invite.starts_with(&format!( + "attn://review/{expected_room_id}#v=3&tier=comment&" + )), + "resolver must preserve the browser-owned room and comment tier" + ); + + let temporary = tempfile::tempdir().expect("native reviewer tempdir"); + let store = Arc::new( + ReviewStore::open_at(temporary.path().join("reviews")).expect("native review store"), + ); + let bootstrap = Bootstrapper::new( + store, + Arc::new(BootstrapConfig { + relay_url, + identity_dir: Some(temporary.path().join("identity")), + }), + ) + .expect("native reviewer bootstrapper"); + let joined = bootstrap + .join(&invite, None) + .await + .expect("native reviewer joins browser-owned room"); + assert_eq!(joined.room_id.as_str(), expected_room_id); + assert_eq!( + joined.local_grant_tier, + Some(attn::review::transport::inbound::GrantTier::Comment) + ); +} + #[tokio::test] #[ignore = "orchestrated by scripts/test-share-e2e.sh against a live local relay"] async fn durable_share_native_real_stack_phase() { diff --git a/web/e2e/hosted-a11y.spec.ts b/web/e2e/hosted-a11y.spec.ts index 281996a0..f724391c 100644 --- a/web/e2e/hosted-a11y.spec.ts +++ b/web/e2e/hosted-a11y.spec.ts @@ -46,7 +46,7 @@ for (const [path, name] of [ test('axe: share sheet open', async ({ page }) => { await page.goto('/app/w/ws-product/direction.md?shell=demo'); await page.getByRole('button', { name: 'Share for review' }).click(); - await expect(page.getByRole('dialog', { name: /Share Product direction/u })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Share for review' })).toBeVisible(); await expectNoAxeViolations(page, 'share sheet'); }); @@ -77,9 +77,9 @@ test('keyboard-only: share sheet opens, traps start focus, and closes', async ({ const share = page.getByRole('button', { name: 'Share for review' }); await share.focus(); await page.keyboard.press('Enter'); - const dialog = page.getByRole('dialog', { name: /Share Product direction/u }); + const dialog = page.getByRole('dialog', { name: 'Share for review' }); await expect(dialog).toBeVisible(); - await expect(dialog.getByRole('button', { name: 'Close' })).toBeFocused(); + await expect(dialog.getByRole('heading', { name: 'Share for review' })).toBeFocused(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); await expect(share).toBeFocused(); diff --git a/web/e2e/hosted-authoring.spec.ts b/web/e2e/hosted-authoring.spec.ts index 27a8810d..5557b8c9 100644 --- a/web/e2e/hosted-authoring.spec.ts +++ b/web/e2e/hosted-authoring.spec.ts @@ -388,11 +388,11 @@ test('remembered rooms can be forgotten with crypto-erasure confirmation', async test('first share is gated on durability until acknowledged', async ({ page }) => { await page.goto('/app/w/ws-product/direction.md?shell=private'); await page.getByRole('button', { name: 'Share for review' }).click(); - const dialog = page.getByRole('dialog', { name: /Share/u }); + const dialog = page.getByRole('dialog', { name: 'Share for review' }); await expect(dialog).toBeVisible(); - const copyLink = dialog.locator('[data-action="copy-link"]'); - await expect(copyLink).toBeDisabled(); - await expect(dialog).toContainText('Sharing unlocks once storage is persistent'); - await dialog.getByRole('checkbox').check(); - await expect(copyLink).toBeEnabled(); + const createLink = dialog.getByRole('button', { name: 'Create review link' }); + await expect(createLink).toBeDisabled(); + await expect(dialog).toContainText('Private browsing is session-only'); + await dialog.getByRole('checkbox', { name: /I understand this browser may erase/u }).check(); + await expect(createLink).toBeEnabled(); }); diff --git a/web/e2e/hosted-share-sheet.spec.ts b/web/e2e/hosted-share-sheet.spec.ts new file mode 100644 index 00000000..168ae3a8 --- /dev/null +++ b/web/e2e/hosted-share-sheet.spec.ts @@ -0,0 +1,183 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +const WORKSPACE_URL = '/app/w/ws-product/direction.md?shell=demo'; + +async function openShare(page: Page) { + await page.goto(WORKSPACE_URL); + const dockShare = page.locator('.thumb-dock').getByRole('button', { name: 'Share' }); + if (await dockShare.isVisible()) await dockShare.click(); + else await page.getByRole('button', { name: 'Share for review' }).click(); + const dialog = page.getByRole('dialog', { name: 'Share for review' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Create review link' })).toBeVisible(); + return dialog; +} + +async function createReadyShare(page: Page) { + const dialog = await openShare(page); + await dialog.getByRole('button', { name: 'Create review link' }).click(); + await expect(dialog.getByRole('heading', { name: 'Review link ready' })).toBeVisible(); + return dialog; +} + +test('defaults to the focused current-file and hybrid delivery flow', async ({ page }) => { + const dialog = await openShare(page); + + await expect(dialog.getByRole('heading', { name: 'Share for review' })).toBeFocused(); + await expect(dialog.getByRole('radio', { name: /Current file/u })).toBeChecked(); + await expect(dialog.locator('.share-manifest')).toContainText('1 entry'); + await expect(dialog.locator('.share-manifest')).toContainText('1 Markdown'); + await expect(dialog.getByText('Delivery mode Hybrid')).toBeVisible(); + + await dialog.getByText('Delivery mode Hybrid').click(); + await expect(dialog.getByRole('radio', { name: /^Hybrid/u })).toBeChecked(); + await expect(dialog).toContainText('Stable links renew for 90 days'); +}); + +test('durability states gate sharing honestly', async ({ page }) => { + await page.goto('/app/w/ws-product/direction.md?shell=private'); + await page.getByRole('button', { name: 'Share for review' }).click(); + const privateDialog = page.getByRole('dialog', { name: 'Share for review' }); + const create = privateDialog.getByRole('button', { name: 'Create review link' }); + await expect(privateDialog).toContainText('Private browsing is session-only'); + await expect(create).toBeDisabled(); + await privateDialog.getByRole('checkbox', { name: /I understand this browser may erase/u }).check(); + await expect(create).toBeEnabled(); + + await page.keyboard.press('Escape'); + await page.goto('/app/w/ws-product/direction.md?shell=quota'); + await page.getByRole('button', { name: 'Share for review' }).click(); + const quotaDialog = page.getByRole('dialog', { name: 'Share for review' }); + await expect(quotaDialog.getByRole('alert')).toContainText('Sharing stays unavailable'); + await expect(quotaDialog.getByRole('button', { name: 'Create review link' })).toBeDisabled(); +}); + +test('each tier matches across browser, native, and CLI while sibling bearers stay distinct', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, 'clipboard', { + configurable: true, + get: () => ({ + writeText: async (value: string) => { + (globalThis as typeof globalThis & { __attnCopied?: string }).__attnCopied = value; + }, + }), + }); + }); + const dialog = await createReadyShare(page); + await expect(dialog.getByRole('radio', { name: /Comment/u })).toBeChecked(); + await dialog.getByText('Native app and CLI options').click(); + const tierSecrets: string[] = []; + let shareId = ''; + for (const tier of ['View-only', 'Comment', 'Suggest']) { + await dialog.getByRole('radio', { name: new RegExp(tier, 'u') }).check(); + const browserCode = dialog.locator('.share-link-row code'); + await expect(browserCode).toContainText('#key=••••'); + await dialog.getByRole('button', { name: 'Show' }).click(); + const browserUrl = (await browserCode.textContent()) ?? ''; + const inviteCodes = dialog.locator('.share-invite-option code'); + const nativeUrl = (await inviteCodes.nth(0).textContent()) ?? ''; + const cliCommand = (await inviteCodes.nth(1).textContent()) ?? ''; + const browser = new URL(browserUrl); + const native = new URL(nativeUrl); + expect(browser.pathname).toMatch(/^\/s\/[A-Za-z0-9_-]+$/u); + expect(native.protocol).toBe('attn:'); + expect(native.hostname).toBe('share'); + expect(native.hash).toBe(browser.hash); + expect(cliCommand).toContain(nativeUrl); + shareId ||= browser.pathname.split('/').at(-1) ?? ''; + expect(browser.pathname).toBe(`/s/${shareId}`); + tierSecrets.push(browser.hash); + await dialog.getByRole('button', { name: 'Hide' }).click(); + } + expect(new Set(tierSecrets).size).toBe(3); + await dialog.getByRole('radio', { name: /Comment/u }).check(); + await dialog.getByRole('button', { name: 'Show' }).click(); + const browserUrl = (await dialog.locator('.share-link-row code').textContent()) ?? ''; + await dialog.getByRole('button', { name: /Copy Comment link/u }).click(); + expect(await page.evaluate(() => + (globalThis as typeof globalThis & { __attnCopied?: string }).__attnCopied, + )).toBe(browserUrl); + + await dialog.getByRole('button', { name: 'Stop sharing' }).click(); + await dialog.getByRole('button', { name: 'Stop now' }).click(); + await expect(dialog.getByRole('heading', { name: 'Review access is off' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Create a new review link' }).click(); + await dialog.getByRole('button', { name: 'Create review link' }).click(); + await expect(dialog.getByRole('heading', { name: 'Review link ready' })).toBeVisible(); +}); + +test('uses Web Share when available and remains axe-clean at mobile width', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, 'share', { + configurable: true, + value: async (payload: ShareData) => { + (globalThis as typeof globalThis & { __attnShared?: ShareData }).__attnShared = payload; + }, + }); + }); + await page.setViewportSize({ width: 320, height: 700 }); + await page.goto(WORKSPACE_URL); + await page.locator('.thumb-dock').getByRole('button', { name: 'Share' }).click(); + const dialog = page.getByRole('dialog', { name: 'Share for review' }); + await dialog.getByRole('button', { name: 'Create review link' }).click(); + await expect(dialog.getByRole('heading', { name: 'Review link ready' })).toBeVisible(); + + await dialog.getByRole('button', { name: /Share Comment link/u }).click(); + const payload = await page.evaluate(() => + (globalThis as typeof globalThis & { __attnShared?: ShareData }).__attnShared, + ); + expect(payload?.url).toMatch(/^https:\/\/attn\.sh\/s\/.+#key=.+/u); + + const overflow = await page.evaluate(() => { + const root = document.scrollingElement; + return root ? root.scrollWidth - root.clientWidth : 0; + }); + expect(overflow).toBe(0); + const violations = (await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze()).violations; + expect(violations.map(({ id }) => id)).toEqual([]); +}); + +test('falls back to clipboard when Web Share rejects', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, 'share', { + configurable: true, + value: async () => { + throw new DOMException('share unavailable', 'NotAllowedError'); + }, + }); + Object.defineProperty(Navigator.prototype, 'clipboard', { + configurable: true, + get: () => ({ + writeText: async (value: string) => { + (globalThis as typeof globalThis & { __attnCopied?: string }).__attnCopied = value; + }, + }), + }); + }); + const dialog = await createReadyShare(page); + await dialog.getByRole('button', { name: /Share Comment link/u }).click(); + const copied = await page.evaluate(() => + (globalThis as typeof globalThis & { __attnCopied?: string }).__attnCopied, + ); + expect(copied).toMatch(/^https:\/\/attn\.sh\/s\/.+#key=.+/u); + await expect(dialog.getByRole('status').filter({ hasText: /browser link was copied/u }).first()).toBeVisible(); +}); + +test('mobile permission tier rows meet 44px touch targets', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }); + const dialog = await createReadyShare(page); + const tierHeights = await dialog.locator('.share-tier-picker label').evaluateAll((labels) => + labels.map((label) => label.getBoundingClientRect().height), + ); + for (const height of tierHeights) expect(height).toBeGreaterThanOrEqual(44); +}); + +test('destructive confirmation takes keyboard focus', async ({ page }) => { + const dialog = await openShare(page); + await dialog.getByRole('button', { name: 'Create review link' }).click(); + await dialog.getByRole('button', { name: 'Stop sharing' }).click(); + await expect(dialog.getByRole('button', { name: 'Keep sharing' })).toBeFocused(); +}); diff --git a/web/e2e/hosted-shells.spec.ts b/web/e2e/hosted-shells.spec.ts index 6d670b54..5ec61512 100644 --- a/web/e2e/hosted-shells.spec.ts +++ b/web/e2e/hosted-shells.spec.ts @@ -63,12 +63,13 @@ test('asset entries render inline previews and download-only placeholders', asyn test('share sheet opens as a dialog and returns focus on close', async ({ page }) => { await page.goto('/app/w/ws-product/direction.md?shell=demo'); await page.getByRole('button', { name: 'Share for review' }).click(); - const dialog = page.getByRole('dialog', { name: /Share Product direction/u }); + const dialog = page.getByRole('dialog', { name: 'Share for review' }); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText('Share the whole workspace · 5 entries'); - await expect(dialog).toContainText('3 Markdown files and 2 referenced assets'); - await expect(dialog).toContainText('Capability keys stay in the fragment'); - // Close button holds initial focus; Escape closes and restores focus. + await expect(dialog).toContainText('Choose the review scope'); + await expect(dialog).toContainText('1 entry · 18 KB'); + await expect(dialog).toContainText('The encryption key stays in the invite link'); + // The dialog heading owns initial focus; Escape closes and restores focus. + await expect(dialog.getByRole('heading', { name: 'Share for review' })).toBeFocused(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); await expect(page.getByRole('button', { name: 'Share for review' })).toBeFocused(); @@ -153,7 +154,7 @@ test('share sheet fits 320px without page overflow', async ({ page }) => { await page.setViewportSize({ width: 320, height: 700 }); await page.goto('/app/w/ws-product/direction.md?shell=demo'); await page.locator('.thumb-dock').getByRole('button', { name: 'Share' }).click(); - await expect(page.getByRole('dialog', { name: /Share Product direction/u })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Share for review' })).toBeVisible(); await expectNoHorizontalScroll(page); }); diff --git a/web/package.json b/web/package.json index 79816599..25c6df21 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "test:e2e:routes": "npm run build:browser && npm run check:route-bundles && playwright test --config playwright.routes.config.ts", "test:e2e:storage": "playwright test --config playwright.storage.config.ts", "test:e2e:push": "playwright test --config playwright.push.config.ts", + "test:share-owner:live": "tsx scripts/test-browser-share-owner-live.ts", "build:sw": "vite build --config vite.sw.config.ts" }, "dependencies": { diff --git a/web/playwright.routes.config.ts b/web/playwright.routes.config.ts index a4a33286..e40091d5 100644 --- a/web/playwright.routes.config.ts +++ b/web/playwright.routes.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'hosted-a11y.spec.ts', 'hosted-authoring.spec.ts', 'hosted-offline.spec.ts', + 'hosted-share-sheet.spec.ts', ], timeout: 60_000, expect: { timeout: 20_000 }, diff --git a/web/scripts/test-browser-share-owner-live.ts b/web/scripts/test-browser-share-owner-live.ts new file mode 100644 index 00000000..5874238f --- /dev/null +++ b/web/scripts/test-browser-share-owner-live.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env -S npx tsx +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { IDBFactory } from 'fake-indexeddb'; + +import { + base64UrlEncode, + buildAdmissionHeaderV3, + deriveShareLinkKeys, +} from '../src/lib/review/browser-crypto'; +import { createOwnedRoomV3, deleteOwnedRoomV3 } from '../src/lib/review/browser-owner-bootstrap'; +import { mineBrowserPow, type BrowserPowInputs } from '../src/lib/review/browser-pow'; +import { + BrowserShareOwnerRelayClient, + EMPTY_SHARE_MANIFEST_DIGEST, + buildShareBundleMutations, + sealDurableShareSnapshot, +} from '../src/lib/review/browser-share-owner'; +import { openShareCapabilityBundle } from '../src/lib/review/browser-share'; +import { + BrowserDurableSharePersistence, + createProductionDurableShareSession, + decryptDurableShareSnapshot, +} from '../src/lib/review/browser-share-production'; +import type { BrowserShareSessionState } from '../src/lib/review/browser-share-session'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(webRoot, '..'); +const relayRoot = path.resolve(webRoot, '..', 'relay'); +const port = 8792; +const relayUrl = `http://127.0.0.1:${port}`; +const relay = spawn(path.join(relayRoot, 'node_modules', '.bin', 'wrangler'), [ + 'dev', '--env', 'staging', '--local', '--port', String(port), + '--var', 'QUOTA_ALLOW_UNATTRIBUTED_CREATES:true', + '--var', 'BLOB_CAP_SIGNING_KEY:local-e2e-blob-cap-signing-key-32bytes', +], { cwd: relayRoot, stdio: ['ignore', 'pipe', 'pipe'] }); +let relayLog = ''; +relay.stdout.on('data', chunk => { relayLog += String(chunk); }); +relay.stderr.on('data', chunk => { relayLog += String(chunk); }); + +function inlineMintPow(input: Omit): Promise { + const rand = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))); + return Promise.resolve(mineBrowserPow({ ...input, expiresAt: Date.now() + 60_000, rand }).token); +} +async function waitForRelay(): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { if ((await fetch(`${relayUrl}/health`)).ok) return; } catch { /* retry */ } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error('relay did not become healthy'); +} +let failures = 0; +function check(value: unknown, label: string): void { + if (value) console.log(`PASS ${label}`); + else { failures += 1; console.error(`FAIL ${label}`); } +} + +async function nativeReviewerJoins(input: { + shareId: string; + roomId: string; + linkSecret: Uint8Array; +}): Promise { + const child = spawn('cargo', [ + 'test', '--test', 'durable_share_native_e2e', + 'browser_owned_share_native_reviewer_real_stack', + '--', '--ignored', '--exact', '--nocapture', + ], { + cwd: repoRoot, + env: { + ...process.env, + ATTN_RELAY_URL: relayUrl, + ATTN_BROWSER_OWNER_SHARE_ID: input.shareId, + ATTN_BROWSER_OWNER_ROOM_ID: input.roomId, + ATTN_BROWSER_OWNER_LINK_SECRET: base64UrlEncode(input.linkSecret), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', chunk => { output += String(chunk); }); + child.stderr.on('data', chunk => { output += String(chunk); }); + const status = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', resolve); + }); + if (status !== 0) console.error(output.split('\n').slice(-40).join('\n')); + return status === 0; +} + +try { + await waitForRelay(); + const room = await createOwnedRoomV3({ relayUrl, mintPow: inlineMintPow }); + check(room.created, 'browser creates the ordinary v3 epoch room'); + const shareId = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))); + const shareSecret = crypto.getRandomValues(new Uint8Array(32)); + const client = new BrowserShareOwnerRelayClient({ + relayUrl, shareId, identity: room.identity, mintPow: inlineMintPow, + }); + const context = (revision: number, manifestDigest: string) => ({ + shareId, shareSecret, epoch: 0, revision, manifestDigest, roomId: room.roomId, + ownerSigningKey: base64UrlEncode(room.identity.signingPublic), + readCapabilityKey: room.keys.readKeys.readCapabilityKey, + writeAdmissionKey: room.keys.writeAdmissionKey, + commentGrantSignature: room.commentGrantSignature, + suggestGrantSignature: room.suggestGrantSignature, + }); + const dark = await client.upsert({ + v: 3, ownerSigningKey: base64UrlEncode(room.identity.signingPublic), + bundles: buildShareBundleMutations(context(0, EMPTY_SHARE_MANIFEST_DIGEST)), + epoch: 0, revision: 0, currentRoomId: null, snapshots: [], placeholders: [], + deviceId: room.identity.deviceId, + }); + check(dark.currentRoomId === undefined, 'ShareDO stays dark before retained upload'); + + const fileId = base64UrlEncode(new Uint8Array(16).fill(21)); + const snapshotId = base64UrlEncode(new Uint8Array(16).fill(23)); + const sealed = sealDurableShareSnapshot({ + shareId, epoch: 0, fileId, snapshotId, docType: 'markdown', content: '# Live browser owner\n', + snapshotKey: room.keys.readKeys.snapshotKey, + }); + await client.uploadSnapshot(fileId, snapshotId, sealed); + sealed.fill(0); + const retained = await client.fetchWithViewCapability(shareSecret); + check(retained.revision === 1 && retained.snapshots.length === 1, 'relay retains opaque snapshot and advances revision'); + const finalRevision = retained.revision + 1; + const active = await client.upsert({ + v: 3, ownerSigningKey: retained.ownerSigningKey, + bundles: buildShareBundleMutations(context(finalRevision, retained.manifestDigest)), + epoch: 0, revision: finalRevision, currentRoomId: room.roomId, + snapshots: retained.snapshots, placeholders: retained.placeholders, + deviceId: room.identity.deviceId, + }); + check(active.currentRoomId === room.roomId, 'final pointer flip activates stable share'); + + const viewKeys = deriveShareLinkKeys(shareSecret, 'view'); + const sharePath = `/v3/shares/${shareId}`; + const recordResponse = await fetch(`${relayUrl}${sharePath}`, { headers: { + 'Attn-Share-Bundle': viewKeys.bundleId, + 'Attn-Admission': buildAdmissionHeaderV3(viewKeys.readAdmissionKey, 'read', 'GET', sharePath, new Uint8Array(0)), + } }); + const publicRecord = await recordResponse.json() as { bundle: { sealedBundle: string } }; + const bundle = openShareCapabilityBundle(viewKeys.bundleKey, viewKeys.bundleId, { + shareId, epoch: 0, revision: finalRevision, manifestDigest: active.manifestDigest, tier: 'view', + }, publicRecord.bundle.sealedBundle); + const snapshotPath = `${sharePath}/snapshots/${fileId}`; + const snapshotResponse = await fetch(`${relayUrl}${snapshotPath}`, { headers: { + 'Attn-Share-Bundle': viewKeys.bundleId, + 'Attn-Admission': buildAdmissionHeaderV3(viewKeys.readAdmissionKey, 'read', 'GET', snapshotPath, new Uint8Array(0)), + } }); + const downloaded = new Uint8Array(await snapshotResponse.arrayBuffer()); + const opened = decryptDurableShareSnapshot(shareId, 0, { + v: 3, shareId, bundleId: bundle.bundleId, epoch: 0, revision: finalRevision, + manifestDigest: active.manifestDigest, roomId: room.roomId, tier: 'view', + roomCapability: { ownerSigningKey: bundle.ownerSigningKey, + readCapabilityKey: room.keys.readKeys.readCapabilityKey, roomKeys: room.keys.readKeys }, + }, fileId, snapshotId, downloaded); + check(opened.content === '# Live browser owner\n', 'view bearer resolves and decrypts browser-owned snapshot'); + + const emptyCommentMailbox = await client.fetchMailbox(shareSecret, 'comment', 0); + check(emptyCommentMailbox.items.length === 0 && emptyCommentMailbox.nextAfter === 0, + 'browser owner authenticates the isolated comment mailbox query'); + await client.ackMailbox(0); + check(true, 'browser owner signs the mailbox ACK query'); + + const commentKeys = deriveShareLinkKeys(shareSecret, 'comment'); + try { + let browserReviewerState: BrowserShareSessionState | null = null; + const persistence = await BrowserDurableSharePersistence.open(new IDBFactory()); + const browserReviewer = await createProductionDurableShareSession({ + relayUrl, + invite: { shareId, linkSecret: new Uint8Array(commentKeys.linkSecret) }, + tier: 'comment', + persistence, + disableWebRtc: true, + liveStore: { + currentRoomId: null, + applyEvent: () => undefined, + applySnapshot: () => undefined, + setCurrentFile: () => undefined, + setCurrentSnapshot: () => undefined, + }, + registrationMintPow: input => inlineMintPow(input), + outboxMintPow: input => inlineMintPow(input), + mailboxMintPow: input => inlineMintPow({ + roomId: input.shareId, + deviceId: input.deviceId, + method: 'POST', + path: input.path, + difficulty: 12, + }), + onState: state => { browserReviewerState = state; }, + }); + await browserReviewer.start(); + const browserDeadline = Date.now() + 20_000; + while (Date.now() < browserDeadline + && (browserReviewerState?.status !== 'ready' || !browserReviewerState.ownerOnline)) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + const browserJoined = browserReviewerState?.status === 'ready' && browserReviewerState.ownerOnline; + check(browserJoined, 'production browser reviewer resolves and joins the browser-owned room live'); + if (!browserJoined) console.error('browser reviewer state:', browserReviewerState); + browserReviewer.close(); + + check(await nativeReviewerJoins({ + shareId, + roomId: room.roomId, + linkSecret: commentKeys.linkSecret, + }), 'native reviewer resolves the browser stable bearer and joins its v3 room'); + } finally { + commentKeys.linkSecret.fill(0); + commentKeys.bundleKey.fill(0); + commentKeys.readAdmissionKey.fill(0); + commentKeys.writeAdmissionKey?.fill(0); + } + + check(!relayLog.includes('# Live browser owner') && !relayLog.includes(base64UrlEncode(shareSecret)), + 'relay logs contain neither plaintext snapshot nor stable owner secret'); + + await client.revoke(); + check((await fetch(`${relayUrl}${sharePath}`, { headers: { + 'Attn-Share-Bundle': viewKeys.bundleId, + 'Attn-Admission': buildAdmissionHeaderV3(viewKeys.readAdmissionKey, 'read', 'GET', sharePath, new Uint8Array(0)), + } })).status === 404, 'owner-signed ShareDO revoke kills stable bearer'); + check(await deleteOwnedRoomV3({ relayUrl, roomId: room.roomId, identity: room.identity, + writeAdmissionKey: room.keys.writeAdmissionKey, mintPow: inlineMintPow }), 'epoch room teardown accepted'); +} catch (error) { + failures += 1; + console.error('FAIL live browser share owner:', error); + console.error(relayLog.split('\n').slice(-30).join('\n')); +} finally { + relay.kill('SIGTERM'); +} + +console.log(failures === 0 ? 'browser-share-owner-live: all green' : `browser-share-owner-live: ${failures} failures`); +process.exit(failures === 0 ? 0 : 1); diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte index fd7de5a2..6ec99bf4 100644 --- a/web/src/hosted/app/EditorShell.svelte +++ b/web/src/hosted/app/EditorShell.svelte @@ -14,6 +14,7 @@ WorkspaceAppService, WorkspaceDetail, WorkspaceEntry, + WorkspaceShareRequest, } from './types'; import type EditorComponentType from '../../lib/Editor.svelte'; import type ReviewMarginComponentType from '../../lib/ReviewMargin.svelte'; @@ -42,6 +43,8 @@ let filesSheetOpen = $state(false); let reviewSheetOpen = $state(false); let shareButton = $state(); + let dockShareButton = $state(); + let shareReturnFocus = $state(); let dockFilesButton = $state(); let dockReviewButton = $state(); let desktopLayout = $state( @@ -450,6 +453,15 @@ const entry = activeEntry; if (!currentSession || !state?.roomId || entry?.presentation !== 'editable') { loadedCollabGenerationKey = null; + if (!state?.roomId) { + untrack(() => { + collabSeedRequest += 1; + collabSeed = null; + collabClientId = null; + boundCollabKey = null; + collabEpoch += 1; + }); + } return; } untrack(() => { void ensureEditorGraph(true); }); @@ -695,9 +707,38 @@ return entry.presentation === 'preview' ? '▧ ' : '◇ '; } + function openShare(trigger: HTMLButtonElement | undefined): void { + blurEditor(); + filesSheetOpen = false; + reviewSheetOpen = false; + shareReturnFocus = trigger; + shareOpen = true; + } + function closeShare(): void { shareOpen = false; - shareButton?.focus(); + const trigger = shareReturnFocus ?? shareButton; + shareReturnFocus = undefined; + queueMicrotask(() => trigger?.focus()); + } + + async function inspectWorkspaceShare() { + const granted = await ensureOwnerSession(); + if (!granted) return null; + return granted.inspectShare(); + } + + async function createWorkspaceShare(request: WorkspaceShareRequest) { + await autosave?.flush(); + const granted = await ensureOwnerSession(); + if (!granted) throw new Error('Another tab owns this workspace.'); + return granted.ensureShare(request); + } + + async function stopWorkspaceShare(): Promise { + const granted = await ensureOwnerSession(); + if (!granted) throw new Error('Another tab owns this workspace.'); + await granted.stopShare(); } function closeFilesSheet(): void { @@ -964,10 +1005,7 @@ content={documentSurface} rail={desktopRail} onNavigate={navigateDesktopTree} - onShare={(trigger) => { - shareButton = trigger; - shareOpen = true; - }} + onShare={openShare} onViewport={(viewport) => (canvasEl = viewport ?? undefined)} /> {:else} @@ -1006,7 +1044,14 @@ · {saveState} Open native {/if} - + {/if} @@ -1086,9 +1135,13 @@ {#if shareOpen} service.requestPersistence()} onBackup={() => exportZip()} diff --git a/web/src/hosted/app/ShareSheet.svelte b/web/src/hosted/app/ShareSheet.svelte index 41a7c057..fc9b128d 100644 --- a/web/src/hosted/app/ShareSheet.svelte +++ b/web/src/hosted/app/ShareSheet.svelte @@ -1,177 +1,672 @@ - -
- - + + diff --git a/web/src/hosted/app/app-shell.css b/web/src/hosted/app/app-shell.css index 149425d7..542f89c8 100644 --- a/web/src/hosted/app/app-shell.css +++ b/web/src/hosted/app/app-shell.css @@ -921,6 +921,577 @@ a.workspace-row:hover { margin-top: 1rem; } +/* ShareSheet's modal state machine uses the native dialog top layer so the + rest of the app is inert while it is open. */ +.overlay-stage[open] { + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + border: 0; + background: transparent; + color: var(--ink); +} + +.overlay-stage::backdrop { + background: var(--overlay-veil); +} + +.share-sheet { + width: min(720px, 100%); + max-height: calc(100dvh - 4rem); + overflow: auto; +} + +.share-head { + position: sticky; + top: 0; + z-index: 2; + padding: 1.45rem 1.7rem 1.25rem; + background: color-mix(in srgb, var(--sheet) 96%, transparent); + backdrop-filter: blur(12px); +} + +.share-head h2:focus { + outline: 0; +} + +.share-head p { + max-width: 56ch; + margin: 0.35rem 0 0; + color: var(--hosted-muted); + font: 0.84rem/1.45 var(--sans); +} + +.share-body { + padding: 1.3rem 1.7rem 1.7rem; + font-family: var(--sans); +} + +.share-panel { + padding: 1rem 0 1.2rem; + border-bottom: 1px solid var(--rule); +} + +.share-panel:first-child { + padding-top: 0; +} + +.share-panel.share-blocked { + padding: 1rem; + border: 1px solid var(--rust); + background: color-mix(in srgb, var(--rust) 6%, var(--sheet)); +} + +.share-panel-heading { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.share-panel-heading .step-badge { + width: 25px; + height: 25px; + display: grid; + place-items: center; + border: 1px solid var(--rule); + border-radius: 50%; + font: 0.7rem var(--mono); +} + +.share-panel h3, +.share-progress h3, +.share-ready-head h3, +.share-stop-zone h3, +.share-stopped h3 { + margin: 0; + font: 700 1rem/1.3 var(--sans); +} + +.share-panel-heading p, +.share-progress p, +.share-ready-head p, +.share-stop-zone p, +.share-stopped p { + margin: 0.25rem 0 0; + color: var(--hosted-muted); + font: 0.87rem/1.45 var(--sans); +} + +.share-storage-actions { + margin: 0.8rem 0 0 2.35rem; +} + +.share-check { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0.9rem 0 0 2.35rem; + font: 0.84rem/1.45 var(--sans); +} + +.share-check input, +.share-choice-grid input, +.share-entry-list input, +.share-advanced input { + accent-color: var(--rust); +} + +.share-check input { + margin-top: 0.18rem; +} + +.share-warning, +.share-error { + margin: 0.75rem 0 0; + color: var(--rust-deep); + font: 0.82rem/1.45 var(--sans); +} + +.share-warning { + margin-left: 2.35rem; +} + +.share-choice-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; + margin: 1rem 0 0; + padding: 0; + border: 0; +} + +.share-choice-grid > label, +.share-ttl-options label { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--rule); + background: var(--paper); + cursor: pointer; +} + +.share-choice-grid > label.chosen, +.share-ttl-options label.chosen { + border-color: var(--rust); + background: color-mix(in srgb, var(--rust) 7%, var(--sheet)); +} + +.share-choice-grid > label.choice-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.share-choice-grid strong, +.share-choice-grid small, +.share-advanced strong, +.share-advanced small { + display: block; +} + +.share-choice-grid strong, +.share-advanced strong { + font-size: 0.82rem; +} + +.share-choice-grid small, +.share-advanced small { + margin-top: 0.2rem; + color: var(--hosted-muted); + font: 0.72rem/1.35 var(--sans); +} + +.share-entry-list { + max-height: 200px; + margin-top: 0.7rem; + overflow: auto; + border: 1px solid var(--rule); + background: var(--paper); +} + +.share-entry-list label { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: center; + padding: 0.62rem 0.75rem; + border-bottom: 1px solid var(--rule); + cursor: pointer; +} + +.share-entry-list label:last-child { + border-bottom: 0; +} + +.share-entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 0.78rem var(--mono); +} + +.share-entry-meta { + color: var(--hosted-muted); + font: 0.7rem var(--mono); +} + +.share-manifest { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.35rem 1rem; + margin-top: 0.75rem; + color: var(--hosted-muted); + font: 0.73rem/1.4 var(--mono); +} + +.share-manifest strong { + color: var(--ink); +} + +.share-advanced { + border-bottom: 1px solid var(--rule); +} + +.share-advanced > summary, +.share-invite-options > summary { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; + cursor: pointer; + font: 700 0.84rem var(--sans); +} + +.share-advanced > summary span { + color: var(--hosted-muted); + font: 0.72rem var(--mono); +} + +.share-advanced-body { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.65fr); + gap: 1rem; + padding-bottom: 1rem; +} + +.share-advanced fieldset { + margin: 0; + padding: 0; + border: 0; +} + +.share-advanced legend { + margin-bottom: 0.55rem; + font: 700 0.76rem var(--sans); +} + +.share-advanced fieldset > label { + display: flex; + gap: 0.5rem; + padding: 0.35rem 0; + cursor: pointer; +} + +.share-ttl-options { + display: grid; + gap: 0.4rem; +} + +.share-ttl-options label { + min-height: 44px; + align-items: center; + box-sizing: border-box; + padding: 0.55rem 0.65rem; + font-size: 0.78rem; +} + +.share-config-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 1rem; +} + +.share-config-foot p { + max-width: 38ch; + margin: 0; + color: var(--hosted-muted); + font: 0.76rem/1.4 var(--sans); +} + +.share-progress, +.share-ready-head { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 1.2rem; + border: 1px solid var(--rule); + background: var(--paper); +} + +.share-spinner { + flex: 0 0 auto; + width: 24px; + height: 24px; + border: 2px solid var(--rule); + border-top-color: var(--rust); + border-radius: 50%; + animation: share-spin 0.8s linear infinite; +} + +@keyframes share-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .share-spinner { + animation: none; + } +} + +.share-progress-mark, +.share-ready-mark { + flex: 0 0 auto; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--ink); + color: var(--sheet); + font: 700 0.82rem var(--mono); +} + +.share-ready-mark { + background: var(--green); +} + +.share-progress-steps { + margin: 1.2rem 0 0; + padding: 0; + list-style: none; + color: var(--hosted-muted); + font: 0.8rem/1.4 var(--sans); +} + +.share-progress-steps li { + position: relative; + padding: 0.45rem 0 0.45rem 1.55rem; +} + +.share-progress-steps li::before { + content: ''; + position: absolute; + left: 0; + top: 0.6rem; + width: 9px; + height: 9px; + border: 1px solid var(--rule); + border-radius: 50%; +} + +.share-progress-steps li.complete, +.share-progress-steps li.active { + color: var(--ink); +} + +.share-progress-steps li.complete::before { + border-color: var(--green); + background: var(--green); +} + +.share-progress-steps li.active::before { + border-color: var(--rust); +} + +.share-tier-picker { + display: grid; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0; + border: 0; +} + +.share-tier-picker legend { + margin-bottom: 0.45rem; + color: var(--ink); + font: 650 0.82rem/1.3 var(--sans); +} + +.share-tier-picker label { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.7rem; + align-items: center; + min-height: 48px; + padding: 0.65rem 0.75rem; + border: 1px solid var(--rule); + background: var(--paper); + cursor: pointer; +} + +.share-tier-picker label.active { + border-color: var(--rust); + background: var(--sheet); +} + +.share-tier-picker label span, +.share-tier-picker label small { + display: block; +} + +.share-tier-picker label small { + margin-top: 0.15rem; + color: var(--hosted-muted); + font: 0.72rem/1.35 var(--sans); +} + +.share-tier-picker label em { + color: var(--rust); + font: 700 0.68rem/1 var(--mono); + font-style: normal; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.share-lifetime-note { + align-self: start; + margin: 1.65rem 0 0; + color: var(--hosted-muted); + font: 0.76rem/1.5 var(--sans); +} + +.share-link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.6rem; + margin-top: 1rem; +} + +.share-link-row code { + min-width: 0; + padding: 0.8rem; + overflow: hidden; + overflow-wrap: anywhere; + border: 1px solid var(--rule); + background: var(--paper); + font: 0.72rem/1.45 var(--mono); +} + +.share-secret-note { + margin: 0.55rem 0 0; + color: var(--hosted-muted); + font: 0.74rem/1.4 var(--sans); +} + +.share-ready-actions { + display: flex; + gap: 0.6rem; + margin-top: 1rem; +} + +.share-feedback { + margin: 0.6rem 0 0; + color: var(--green); + font: 0.76rem/1.4 var(--sans); +} + +.share-invite-options { + margin-top: 1rem; + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); +} + +.share-invite-option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.7rem; + align-items: center; + padding: 0.7rem 0; + border-top: 1px solid var(--rule); +} + +.share-invite-option strong, +.share-invite-option code { + display: block; +} + +.share-invite-option strong { + margin-bottom: 0.25rem; + font-size: 0.78rem; +} + +.share-invite-option code { + overflow-wrap: anywhere; + color: var(--hosted-muted); + font: 0.7rem/1.4 var(--mono); +} + +.share-invite-actions { + display: flex; + gap: 0.45rem; + align-items: center; +} + +.share-stop-zone { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-top: 1rem; + padding: 0.9rem 0 0; +} + +.share-stop-actions, +.share-foot { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.share-stopped { + padding: 1.5rem 1rem; + text-align: center; +} + +.share-stopped > span { + width: 34px; + height: 34px; + display: grid; + place-items: center; + margin: 0 auto 0.8rem; + border-radius: 50%; + background: var(--green); + color: var(--sheet); +} + +.share-stopped .button { + margin-top: 1rem; +} + +.share-sheet .button:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +.share-clipboard-field, +.sr-only { + position: fixed; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* ————— storage & open ————— */ .storage-grid { @@ -1075,13 +1646,77 @@ a.workspace-row:hover { flex: 1 1 12rem; } - .overlay-stage { - padding: 1rem; - place-items: start center; + .overlay-stage[open] { + padding: 0; + place-items: end center; + overflow: hidden; } .share-sheet { - margin-top: 3.5rem; + width: 100%; + max-height: min(88dvh, 760px); + margin: 0; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 14px 14px 0 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + .share-sheet::before { + content: ''; + display: block; + position: sticky; + top: 0; + z-index: 3; + width: 34px; + height: 4px; + margin: 0.45rem auto -0.6rem; + border-radius: 999px; + background: var(--rule); + } + + .share-head { + padding: 1.25rem 1.1rem 1rem; + } + + .share-body { + padding: 1rem 1.1rem 1.25rem; + } + + .share-choice-grid, + .share-advanced-body { + grid-template-columns: 1fr; + } + + .share-choice-grid > label { + padding: 0.65rem; + } + + .share-config-foot, + .share-stop-zone { + align-items: stretch; + flex-direction: column; + } + + .share-config-foot .button, + .share-ready-actions .button, + .share-stop-zone > .button, + .share-stop-actions .button { + flex: 1 1 auto; + } + + .share-entry-list label { + grid-template-columns: auto minmax(0, 1fr); + } + + .share-entry-meta { + grid-column: 2; + } + + .share-invite-option { + align-items: stretch; + grid-template-columns: 1fr; } } diff --git a/web/src/hosted/app/import-export.test.ts b/web/src/hosted/app/import-export.test.ts index 95f317f6..11dac353 100644 --- a/web/src/hosted/app/import-export.test.ts +++ b/web/src/hosted/app/import-export.test.ts @@ -8,6 +8,9 @@ import { } from './export-zip'; import { MAX_IMPORT_FILE_BYTES, + MAX_IMPORT_ARCHIVE_ENTRIES, + MAX_IMPORT_EXPANDED_BYTES, + acceptZipEntry, dedupeWorkspaceName, expandPicked, expandZip, @@ -60,6 +63,15 @@ function assertThrows(fn: () => unknown, message: string): void { throw new Error(`${message}: expected an error`); } +async function assertRejects(fn: () => Promise, message: string): Promise { + try { + await fn(); + } catch { + return; + } + throw new Error(`${message}: expected an error`); +} + function picked(name: string, bytes: Uint8Array, relativePath?: string, type = ''): PickedFile { return { name, bytes, type, ...(relativePath === undefined ? {} : { relativePath }) }; } @@ -114,8 +126,26 @@ defineCase('zip expansion preserves nested paths and skips junk entries', async defineCase('zip with traversal paths aborts the whole import', async () => { const zip = zipSync({ '../escape.md': new TextEncoder().encode('bad') }); - const files = await expandZip(picked('evil.zip', zip)); - assertThrows(() => toImportFiles(files), 'zip traversal rejected'); + await assertRejects(() => expandZip(picked('evil.zip', zip)), 'zip traversal rejected before expansion'); +}); + +defineCase('zip metadata budgets reject bombs before expansion', () => { + assertThrows(() => acceptZipEntry( + { name: 'huge.bin', originalSize: MAX_IMPORT_FILE_BYTES + 1 }, + { entries: 0, expandedBytes: 0 }, + ), 'oversized zip entry rejected'); + assertThrows(() => acceptZipEntry( + { name: 'overflow.bin', originalSize: 1 }, + { entries: MAX_IMPORT_ARCHIVE_ENTRIES, expandedBytes: 0 }, + ), 'zip entry-count bomb rejected'); + assertThrows(() => acceptZipEntry( + { name: 'overflow.bin', originalSize: 1 }, + { entries: 0, expandedBytes: MAX_IMPORT_EXPANDED_BYTES }, + ), 'zip expanded-byte bomb rejected'); + assertThrows(() => acceptZipEntry( + { name: '../escape.md', originalSize: 1 }, + { entries: 0, expandedBytes: 0 }, + ), 'zip traversal rejected by metadata filter'); }); defineCase('corrupt zip is a clear error', async () => { diff --git a/web/src/hosted/app/import-files.ts b/web/src/hosted/app/import-files.ts index 30a257bc..3fae3b45 100644 --- a/web/src/hosted/app/import-files.ts +++ b/web/src/hosted/app/import-files.ts @@ -17,6 +17,14 @@ const MARKDOWN_EXTENSIONS = /\.(?:md|markdown)$/iu; const ZIP_EXTENSION = /\.zip$/iu; /** Per-file input cap: larger inputs are rejected, never silently truncated. */ export const MAX_IMPORT_FILE_BYTES = 64 * 1024 * 1024; +export const MAX_IMPORT_ARCHIVE_BYTES = 64 * 1024 * 1024; +export const MAX_IMPORT_EXPANDED_BYTES = 128 * 1024 * 1024; +export const MAX_IMPORT_ARCHIVE_ENTRIES = 1024; + +export interface ZipEntryBudget { + entries: number; + expandedBytes: number; +} export function kindForFile(name: string): WorkspaceEntryKind { return MARKDOWN_EXTENSIONS.test(name) ? 'markdown' : 'asset'; @@ -83,18 +91,32 @@ export function mediaTypeForName(name: string): string | undefined { * import rather than silently dropping content. fflate is loaded on demand. */ export async function expandZip(zip: PickedFile): Promise { + if (zip.bytes.length > MAX_IMPORT_ARCHIVE_BYTES) { + throw new EntryPathSafeError( + `${zip.name} is larger than the ${Math.round(MAX_IMPORT_ARCHIVE_BYTES / (1024 * 1024))} MB archive limit`, + ); + } const { unzipSync } = await import('fflate'); let entries: Record; + const budget: ZipEntryBudget = { entries: 0, expandedBytes: 0 }; try { - entries = unzipSync(zip.bytes); - } catch { + entries = unzipSync(zip.bytes, { + filter: (entry) => acceptZipEntry(entry, budget), + }); + } catch (error) { + if (error instanceof EntryPathSafeError) throw error; throw new EntryPathSafeError(`${zip.name} is not a readable zip archive`); } const files: PickedFile[] = []; + let actualExpandedBytes = 0; for (const [rawPath, bytes] of Object.entries(entries)) { if (rawPath.endsWith('/')) continue; // directory marker if (rawPath.startsWith('__MACOSX/') || rawPath.split('/').pop() === '.DS_Store') continue; const name = rawPath.split('/').pop() ?? rawPath; + actualExpandedBytes += bytes.length; + if (!Number.isSafeInteger(actualExpandedBytes) || actualExpandedBytes > MAX_IMPORT_EXPANDED_BYTES) { + throw new EntryPathSafeError('The expanded archive is larger than the 128 MB import limit'); + } files.push({ name, relativePath: rawPath, @@ -108,6 +130,34 @@ export async function expandZip(zip: PickedFile): Promise { return files; } +export function acceptZipEntry( + entry: { name: string; originalSize: number }, + budget: ZipEntryBudget, +): boolean { + if (entry.name.endsWith('/') || entry.name.startsWith('__MACOSX/') + || entry.name.split('/').pop() === '.DS_Store') return false; + normalizeEntryPath(entry.name); + if (!Number.isSafeInteger(entry.originalSize) || entry.originalSize < 0) { + throw new EntryPathSafeError('The archive contains an invalid entry size'); + } + if (entry.originalSize > MAX_IMPORT_FILE_BYTES) { + throw new EntryPathSafeError( + `${entry.name} is larger than the ${Math.round(MAX_IMPORT_FILE_BYTES / (1024 * 1024))} MB import limit`, + ); + } + const entries = budget.entries + 1; + const expandedBytes = budget.expandedBytes + entry.originalSize; + if (entries > MAX_IMPORT_ARCHIVE_ENTRIES) { + throw new EntryPathSafeError(`The archive contains more than ${MAX_IMPORT_ARCHIVE_ENTRIES} files`); + } + if (!Number.isSafeInteger(expandedBytes) || expandedBytes > MAX_IMPORT_EXPANDED_BYTES) { + throw new EntryPathSafeError('The expanded archive is larger than the 128 MB import limit'); + } + budget.entries = entries; + budget.expandedBytes = expandedBytes; + return true; +} + /** Expand zips (each into its files) and pass everything else through. */ export async function expandPicked(picked: PickedFile[]): Promise { const out: PickedFile[] = []; diff --git a/web/src/hosted/app/mock-service.ts b/web/src/hosted/app/mock-service.ts index 02fa5dcd..06feae6d 100644 --- a/web/src/hosted/app/mock-service.ts +++ b/web/src/hosted/app/mock-service.ts @@ -15,10 +15,32 @@ import type { StorageHealth, WorkspaceAppService, WorkspaceDetail, + WorkspaceShareView, WorkspaceSummary, } from './types'; import type { BrowserOwnerWorkspaceRuntimeState } from '../../lib/review/browser-owner-workspace-runtime'; +const MOCK_INVITE = { + view: { + tier: 'view', + browserUrl: 'https://attn.sh/s/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc', + nativeUrl: 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc', + cliCommand: "npx attnmd review join 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc'", + }, + comment: { + tier: 'comment', + browserUrl: 'https://attn.sh/s/yPJpJifC1HUQgHsJ_7speQ#key=CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg', + nativeUrl: 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg', + cliCommand: "npx attnmd review join 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg'", + }, + suggest: { + tier: 'suggest', + browserUrl: 'https://attn.sh/s/yPJpJifC1HUQgHsJ_7speQ#key=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk', + nativeUrl: 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk', + cliCommand: "npx attnmd review join 'attn://share/yPJpJifC1HUQgHsJ_7speQ#key=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk'", + }, +} as const; + /** 'real' (no ?shell= param) boots the storage-backed service; every other * scenario runs this mock so degraded states stay directly reachable. */ export type ShellScenario = 'real' | 'demo' | 'private' | 'blocked' | 'quota' | 'empty'; @@ -43,11 +65,11 @@ const PRODUCT_DIRECTION: WorkspaceDetail = { backupLabel: 'Backed up today', saveState: 'Saved on this device', entries: [ - { path: 'direction.md', presentation: 'editable', sizeLabel: '18 KB' }, - { path: 'principles.md', presentation: 'editable', sizeLabel: '11 KB' }, - { path: 'open-questions.md', presentation: 'editable', sizeLabel: '6 KB' }, - { path: 'images/desk.png', presentation: 'preview', sizeLabel: '1.9 MB' }, - { path: 'data/notes.json', presentation: 'download-only', sizeLabel: '4 KB' }, + { path: 'direction.md', kind: 'markdown', presentation: 'editable', sizeBytes: 18_432, sizeLabel: '18 KB' }, + { path: 'principles.md', kind: 'markdown', presentation: 'editable', sizeBytes: 11_264, sizeLabel: '11 KB' }, + { path: 'open-questions.md', kind: 'markdown', presentation: 'editable', sizeBytes: 6_144, sizeLabel: '6 KB' }, + { path: 'images/desk.png', kind: 'asset', presentation: 'preview', sizeBytes: 1_992_294, sizeLabel: '1.9 MB', mediaType: 'image/png' }, + { path: 'data/notes.json', kind: 'asset', presentation: 'download-only', sizeBytes: 4_096, sizeLabel: '4 KB', mediaType: 'application/json' }, ], reviewCards: [ { @@ -69,7 +91,7 @@ const LAUNCH_NOTES: WorkspaceDetail = { sizeLabel: '48 KB', backupLabel: 'Never backed up', saveState: 'Saved on this device', - entries: [{ path: 'launch-notes.md', presentation: 'editable', sizeLabel: '48 KB' }], + entries: [{ path: 'launch-notes.md', kind: 'markdown', presentation: 'editable', sizeBytes: 49_152, sizeLabel: '48 KB' }], reviewCards: [], }; @@ -85,9 +107,9 @@ const RESEARCH_FOLIO: WorkspaceDetail = { backupLabel: 'Backed up Jun 28', saveState: 'Saved on this device', entries: [ - { path: 'index.md', presentation: 'editable', sizeLabel: '9 KB' }, - { path: 'interviews/mara.md', presentation: 'editable', sizeLabel: '22 KB' }, - { path: 'figures/latency.png', presentation: 'preview', sizeLabel: '2.2 MB' }, + { path: 'index.md', kind: 'markdown', presentation: 'editable', sizeBytes: 9_216, sizeLabel: '9 KB' }, + { path: 'interviews/mara.md', kind: 'markdown', presentation: 'editable', sizeBytes: 22_528, sizeLabel: '22 KB' }, + { path: 'figures/latency.png', kind: 'asset', presentation: 'preview', sizeBytes: 2_306_867, sizeLabel: '2.2 MB', mediaType: 'image/png' }, ], reviewCards: [], }; @@ -95,6 +117,7 @@ const RESEARCH_FOLIO: WorkspaceDetail = { export class MockWorkspaceService implements WorkspaceAppService { private readonly scenario: ShellScenario; private readonly workspaces: WorkspaceDetail[]; + private mockShare: WorkspaceShareView | null = null; constructor(scenario: ShellScenario) { this.scenario = scenario; @@ -177,8 +200,11 @@ export class MockWorkspaceService implements WorkspaceAppService { saveState: 'Saved on this device', entries: files.map((file) => ({ path: file.path, + kind: file.kind, presentation: file.kind === 'markdown' ? 'editable' : 'preview', + sizeBytes: file.bytes.length, sizeLabel: `${file.bytes.length} B`, + ...(file.mediaType === undefined ? {} : { mediaType: file.mediaType }), })), reviewCards: [], }; @@ -198,7 +224,13 @@ export class MockWorkspaceService implements WorkspaceAppService { async createMarkdownEntry(workspaceId: string, path: string): Promise { const workspace = this.workspaces.find((candidate) => candidate.id === workspaceId); - workspace?.entries.push({ path, presentation: 'editable', sizeLabel: '0 B' }); + workspace?.entries.push({ + path, + kind: 'markdown', + presentation: 'editable', + sizeBytes: 0, + sizeLabel: '0 B', + }); } async addAssetFiles(workspaceId: string, files: ImportFileInput[]): Promise { @@ -206,8 +238,11 @@ export class MockWorkspaceService implements WorkspaceAppService { for (const file of files) { workspace?.entries.push({ path: file.path, + kind: file.kind, presentation: file.kind === 'markdown' ? 'editable' : 'preview', + sizeBytes: file.bytes.length, sizeLabel: `${file.bytes.length} B`, + ...(file.mediaType === undefined ? {} : { mediaType: file.mediaType }), }); } } @@ -310,6 +345,33 @@ export class MockWorkspaceService implements WorkspaceAppService { replyToComment: async () => { throw new Error('Mock review authoring is unavailable.'); }, resolveComment: async () => { throw new Error('Mock review authoring is unavailable.'); }, retryReviewOutbox: async () => undefined, + inspectShare: async () => this.mockShare ? structuredClone(this.mockShare) : null, + ensureShare: async (input) => { + const invite = MOCK_INVITE; + const workspace = this.workspaces.find((candidate) => candidate.id === workspaceId); + const selection = input.selection; + const paths = selection.kind === 'workspace' + ? workspace?.entries.map((entry) => entry.path) ?? [] + : selection.kind === 'file' + ? [selection.path] + : selection.paths; + this.mockShare = { + workspaceId, + capId: 'BwcHBwcHBwcHBwcHBwcHBw', + shareId: 'yPJpJifC1HUQgHsJ_7speQ', + roomId: 'nC6t29PRD0NcUGsRpkBpFg', + scopeKind: selection.kind, + paths, + publication: 'published', + mode: input.mode, + expiresAt: Date.now() + input.ttlMs, + expired: false, + resumable: false, + invite, + }; + return structuredClone(this.mockShare!); + }, + stopShare: async () => { this.mockShare = null; }, release: async () => undefined, }; } @@ -327,7 +389,7 @@ export class MockWorkspaceService implements WorkspaceAppService { sizeLabel: '0 B', backupLabel: 'Never backed up', saveState, - entries: [{ path: 'untitled.md', presentation: 'editable', sizeLabel: '0 B' }], + entries: [{ path: 'untitled.md', kind: 'markdown', presentation: 'editable', sizeBytes: 0, sizeLabel: '0 B' }], reviewCards: [], }; } diff --git a/web/src/hosted/app/real-service.ts b/web/src/hosted/app/real-service.ts index 834f8d87..9c04d1a0 100644 --- a/web/src/hosted/app/real-service.ts +++ b/web/src/hosted/app/real-service.ts @@ -22,6 +22,7 @@ import { type BrowserWorkspaceServiceOptions, } from './workspace-service'; import { quotaPressure } from '../../lib/review/browser-storage-probe'; +import { validateBrowserRelayUrl } from '../../lib/review/browser-relay-url'; import type { WorkspaceEntryRecord } from '../../lib/review/browser-workspace-schema'; /** Safe raster types that may render inline (epic scope note 2026-07-10). */ @@ -245,6 +246,30 @@ export class RealWorkspaceAppService implements WorkspaceAppService { replyToComment: (anchor, body, threadId) => runtime.replyToComment(anchor, body, threadId), resolveComment: (threadId) => runtime.resolveComment(threadId), retryReviewOutbox: () => runtime.retryOutbox(), + inspectShare: () => runtime.inspectShare(browserReviewBase()), + ensureShare: async (input) => { + const mode = this.storageHealth().mode; + if (mode === 'unavailable' || mode === 'quota-pressure') { + throw new Error('Local storage must be writable before creating a review room.'); + } + if (mode !== 'persistent' && !input.riskAcknowledged) { + throw new Error('Acknowledge the local recovery risk before sharing.'); + } + const selection = input.selection; + return runtime.ensureShare({ + relayUrl: validateBrowserRelayUrl(import.meta.env.VITE_ATTN_RELAY_URL), + browserReviewBase: browserReviewBase(), + scopeKind: selection.kind, + paths: selection.kind === 'workspace' + ? [] + : selection.kind === 'file' + ? [selection.path] + : selection.paths, + mode: input.mode, + ttlMs: input.ttlMs, + }); + }, + stopShare: () => runtime.stopShare(), async release(): Promise {}, }; } @@ -320,12 +345,20 @@ export class RealWorkspaceAppService implements WorkspaceAppService { function toViewEntry(entry: WorkspaceEntryRecord): WorkspaceEntry { return { path: entry.path, + kind: entry.kind, presentation: entry.kind === 'markdown' ? 'editable' : entry.mediaType !== undefined && INLINE_SAFE_MEDIA.test(entry.mediaType) ? 'preview' : 'download-only', + sizeBytes: entry.sizeBytes, sizeLabel: sizeLabel(entry.sizeBytes), + ...(entry.mediaType === undefined ? {} : { mediaType: entry.mediaType }), }; } + +function browserReviewBase(): string { + if (typeof window === 'undefined') return 'https://attn.sh/review'; + return `${window.location.origin}/review`; +} diff --git a/web/src/hosted/app/share-sheet-model.test.ts b/web/src/hosted/app/share-sheet-model.test.ts new file mode 100644 index 00000000..38ae7d74 --- /dev/null +++ b/web/src/hosted/app/share-sheet-model.test.ts @@ -0,0 +1,109 @@ +import type { WorkspaceEntry } from './types'; +import { + SHARE_TTL_ONE_DAY, + SHARE_TTL_ONE_HOUR, + createShareRequest, + durabilityState, + entriesForScope, + formatByteCount, + maskInviteUrl, + remainingTimeLabel, + summarizeEntries, +} from './share-sheet-model'; + +interface Result { name: string; ok: boolean; detail?: string } +const cases: Array<() => Result> = []; + +function test(name: string, run: () => void): void { + cases.push(() => { + try { + run(); + return { name, ok: true }; + } catch (error) { + return { + name, + ok: false, + detail: error instanceof Error ? error.stack ?? error.message : String(error), + }; + } + }); +} + +function equal(actual: unknown, expected: unknown, message: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +const entries: WorkspaceEntry[] = [ + { path: 'notes.md', kind: 'markdown', presentation: 'editable', sizeBytes: 1536, sizeLabel: '1.5 KB' }, + { path: 'image.png', kind: 'asset', presentation: 'preview', sizeBytes: 2048, sizeLabel: '2 KB' }, + { path: 'data.bin', kind: 'asset', presentation: 'download-only', sizeBytes: 512, sizeLabel: '512 B' }, +]; + +test('durability gate never allows unavailable or quota-pressure modes', () => { + for (const mode of ['unavailable', 'quota-pressure'] as const) { + equal(durabilityState(mode, true), { + allowed: false, + hardBlocked: true, + needsAcknowledgement: false, + canRequestPersistence: false, + }, `${mode} remains blocked`); + } +}); + +test('best-effort and session-only require explicit risk acknowledgement', () => { + equal(durabilityState('best-effort', false).allowed, false, 'best effort starts locked'); + equal(durabilityState('best-effort', true).allowed, true, 'best effort unlocks after acknowledgement'); + equal(durabilityState('best-effort', false).canRequestPersistence, true, 'best effort can request persistence'); + equal(durabilityState('session-only', true).allowed, true, 'private session can be explicitly acknowledged'); +}); + +test('scope resolution and manifest summary preserve exact paths and sizes', () => { + const selected = entriesForScope(entries, 'entries', 'notes.md', ['notes.md', 'image.png']); + equal(selected.map((entry) => entry.path), ['notes.md', 'image.png'], 'selected paths'); + equal(summarizeEntries(entries), { + entryCount: 3, + markdownCount: 1, + previewableAssetCount: 1, + downloadOnlyAssetCount: 1, + totalBytes: 4096, + }, 'manifest summary'); +}); + +test('request construction uses the configured scope, mode, lifetime, and risk flag', () => { + equal(createShareRequest({ + scope: 'file', + activePath: 'notes.md', + selectedPaths: [], + mode: 'async', + ttlMs: SHARE_TTL_ONE_HOUR, + riskAcknowledged: true, + }), { + selection: { kind: 'file', path: 'notes.md' }, + mode: 'async', + ttlMs: SHARE_TTL_ONE_HOUR, + riskAcknowledged: true, + }, 'file request'); + equal(createShareRequest({ + scope: 'workspace', + selectedPaths: [], + mode: 'hybrid', + ttlMs: SHARE_TTL_ONE_DAY, + riskAcknowledged: false, + })?.selection, { kind: 'workspace' }, 'workspace request'); +}); + +test('invite masking hides fragment material and utility labels remain stable', () => { + equal(maskInviteUrl('https://attn.sh/review/room#key=secret-value'), + 'https://attn.sh/review/room#key=••••••••••••••••', 'masked invite'); + equal(formatByteCount(1536), '1.5 KB', 'byte count'); + equal(remainingTimeLabel(1_000_000 + 60 * 60 * 1000, 1_000_000), '1 hr remaining', 'remaining time'); +}); + +const results = cases.map((run) => run()); +for (const result of results) { + console.log(`${result.ok ? 'ok' : 'not ok'} - ${result.name}`); + if (result.detail) console.error(result.detail); +} +if (results.some((result) => !result.ok)) process.exitCode = 1; diff --git a/web/src/hosted/app/share-sheet-model.ts b/web/src/hosted/app/share-sheet-model.ts new file mode 100644 index 00000000..e0f972cf --- /dev/null +++ b/web/src/hosted/app/share-sheet-model.ts @@ -0,0 +1,176 @@ +import type { + PersistenceMode, + WorkspaceEntry, + WorkspaceShareMode, + WorkspaceShareRequest, + WorkspaceShareSelection, + WorkspaceShareTtlMs, +} from './types'; + +export const SHARE_TTL_ONE_HOUR: WorkspaceShareTtlMs = 3_600_000; +export const SHARE_TTL_ONE_DAY: WorkspaceShareTtlMs = 86_400_000; +export const SHARE_TTL_SEVEN_DAYS: WorkspaceShareTtlMs = 604_800_000; + +export type ShareScopeChoice = 'file' | 'entries' | 'workspace'; + +export interface ShareManifestSummary { + entryCount: number; + markdownCount: number; + previewableAssetCount: number; + downloadOnlyAssetCount: number; + totalBytes: number; +} + +export interface ShareDurabilityState { + allowed: boolean; + hardBlocked: boolean; + needsAcknowledgement: boolean; + canRequestPersistence: boolean; +} + +export const SHARE_TTL_OPTIONS: ReadonlyArray<{ + value: WorkspaceShareTtlMs; + label: string; +}> = [ + { value: SHARE_TTL_ONE_HOUR, label: '1 hour' }, + { value: SHARE_TTL_ONE_DAY, label: '24 hours' }, + { value: SHARE_TTL_SEVEN_DAYS, label: '7 days' }, +]; + +export const SHARE_MODE_OPTIONS: ReadonlyArray<{ + value: WorkspaceShareMode; + label: string; + detail: string; +}> = [ + { + value: 'hybrid', + label: 'Hybrid', + detail: 'Connect directly when possible, with the encrypted relay as a fallback.', + }, + { + value: 'async', + label: 'Async', + detail: 'Keep review available through the encrypted relay while the owner is offline.', + }, + { + value: 'live', + label: 'Live', + detail: 'Use a direct live session; review availability depends on the owner connection.', + }, +]; + +export function durabilityState( + mode: PersistenceMode, + riskAcknowledged: boolean, +): ShareDurabilityState { + if (mode === 'persistent') { + return { + allowed: true, + hardBlocked: false, + needsAcknowledgement: false, + canRequestPersistence: false, + }; + } + if (mode === 'quota-pressure' || mode === 'unavailable') { + return { + allowed: false, + hardBlocked: true, + needsAcknowledgement: false, + canRequestPersistence: false, + }; + } + return { + allowed: riskAcknowledged, + hardBlocked: false, + needsAcknowledgement: true, + canRequestPersistence: mode === 'best-effort', + }; +} + +export function entriesForScope( + entries: readonly WorkspaceEntry[], + scope: ShareScopeChoice, + activePath: string | undefined, + selectedPaths: readonly string[], +): WorkspaceEntry[] { + if (scope === 'workspace') return [...entries]; + if (scope === 'file') { + return activePath ? entries.filter((entry) => entry.path === activePath) : []; + } + const selected = new Set(selectedPaths); + return entries.filter((entry) => selected.has(entry.path)); +} + +export function summarizeEntries(entries: readonly WorkspaceEntry[]): ShareManifestSummary { + const summary: ShareManifestSummary = { + entryCount: entries.length, + markdownCount: 0, + previewableAssetCount: 0, + downloadOnlyAssetCount: 0, + totalBytes: 0, + }; + for (const entry of entries) { + summary.totalBytes += entry.sizeBytes; + if (entry.kind === 'markdown') summary.markdownCount += 1; + else if (entry.presentation === 'preview') summary.previewableAssetCount += 1; + else summary.downloadOnlyAssetCount += 1; + } + return summary; +} + +export function selectionForScope( + scope: ShareScopeChoice, + activePath: string | undefined, + selectedPaths: readonly string[], +): WorkspaceShareSelection | null { + if (scope === 'workspace') return { kind: 'workspace' }; + if (scope === 'file') return activePath ? { kind: 'file', path: activePath } : null; + return selectedPaths.length > 0 ? { kind: 'entries', paths: [...selectedPaths] } : null; +} + +export function createShareRequest(input: { + scope: ShareScopeChoice; + activePath?: string; + selectedPaths: readonly string[]; + mode: WorkspaceShareMode; + ttlMs: WorkspaceShareTtlMs; + riskAcknowledged: boolean; +}): WorkspaceShareRequest | null { + const selection = selectionForScope(input.scope, input.activePath, input.selectedPaths); + if (!selection) return null; + return { + selection, + mode: input.mode, + ttlMs: input.ttlMs, + riskAcknowledged: input.riskAcknowledged, + }; +} + +export function formatByteCount(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** unitIndex; + const digits = unitIndex === 0 || value >= 10 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +export function maskInviteUrl(url: string): string { + const fragmentStart = url.indexOf('#'); + if (fragmentStart < 0) return url; + const prefix = url.slice(0, fragmentStart); + const fragment = url.slice(fragmentStart + 1); + const equals = fragment.indexOf('='); + const keyName = equals < 0 ? 'key' : fragment.slice(0, equals); + return `${prefix}#${keyName}=••••••••••••••••`; +} + +export function remainingTimeLabel(expiresAt: number, now = Date.now()): string { + const remaining = expiresAt - now; + if (remaining <= 0) return 'Expired'; + const minutes = Math.ceil(remaining / 60_000); + if (minutes < 60) return `${minutes} min remaining`; + const hours = Math.ceil(remaining / 3_600_000); + if (hours <= 48) return `${hours} hr remaining`; + return `${Math.ceil(hours / 24)} days remaining`; +} diff --git a/web/src/hosted/app/types.ts b/web/src/hosted/app/types.ts index 86ef3589..2247972c 100644 --- a/web/src/hosted/app/types.ts +++ b/web/src/hosted/app/types.ts @@ -16,8 +16,11 @@ export type WorkspaceEntryKind = 'markdown' | 'asset'; export interface WorkspaceEntry { /** Normalized relative path within the workspace, e.g. `docs/notes.md`. */ path: string; + kind: WorkspaceEntryKind; presentation: EntryPresentation; + sizeBytes: number; sizeLabel: string; + mediaType?: string; } /** Literal status language from planning/web-authoring/00-web-presence.md. */ @@ -100,6 +103,48 @@ import type { RejectBrowserSuggestionResult, } from '../../lib/review/browser-review-actions'; import type { Anchor, ReviewEvent } from '../../lib/types'; +export type WorkspaceShareMode = 'live' | 'async' | 'hybrid'; +export type WorkspaceShareTtlMs = 3_600_000 | 86_400_000 | 604_800_000; + +export interface WorkspaceShareTierInvite { + tier: 'view' | 'comment' | 'suggest'; + browserUrl: string; + nativeUrl: string; + cliCommand: string; +} + +export interface WorkspaceShareInvite { + view: WorkspaceShareTierInvite; + comment: WorkspaceShareTierInvite; + suggest: WorkspaceShareTierInvite; +} + +export interface WorkspaceShareView { + workspaceId: string; + capId: string; + shareId: string; + roomId: string; + scopeKind: 'file' | 'entries' | 'workspace'; + paths: string[]; + publication: 'pending' | 'published' | 'stopped'; + mode: WorkspaceShareMode; + expiresAt: number; + expired: boolean; + resumable: boolean; + invite: WorkspaceShareInvite | null; +} + +export type WorkspaceShareSelection = + | { kind: 'file'; path: string } + | { kind: 'entries'; paths: string[] } + | { kind: 'workspace' }; + +export interface WorkspaceShareRequest { + selection: WorkspaceShareSelection; + mode: WorkspaceShareMode; + ttlMs: WorkspaceShareTtlMs; + riskAcknowledged: boolean; +} /** * Injected async view-service the shells render from (attn-7xl.3.2). The @@ -124,6 +169,9 @@ export interface EditingSession { replyToComment(anchor: Anchor, body: string, threadId: string): Promise; resolveComment(threadId: string): Promise; retryReviewOutbox(): Promise; + inspectShare(): Promise; + ensureShare(input: WorkspaceShareRequest): Promise; + stopShare(): Promise; /** Leaves edit mode; the route-lifetime owner lease remains held. */ release(): Promise; } diff --git a/web/src/hosted/app/workspace-tree.test.ts b/web/src/hosted/app/workspace-tree.test.ts index 04c636e7..bea848d6 100644 --- a/web/src/hosted/app/workspace-tree.test.ts +++ b/web/src/hosted/app/workspace-tree.test.ts @@ -6,9 +6,27 @@ import { } from './workspace-tree'; const entries = [ - { path: 'readme.md', presentation: 'editable' as const, sizeLabel: '1 KB' }, - { path: 'docs/notes.md', presentation: 'editable' as const, sizeLabel: '2 KB' }, - { path: 'images/dot.png', presentation: 'preview' as const, sizeLabel: '3 KB' }, + { + path: 'readme.md', + kind: 'markdown' as const, + presentation: 'editable' as const, + sizeBytes: 1_024, + sizeLabel: '1 KB', + }, + { + path: 'docs/notes.md', + kind: 'markdown' as const, + presentation: 'editable' as const, + sizeBytes: 2_048, + sizeLabel: '2 KB', + }, + { + path: 'images/dot.png', + kind: 'asset' as const, + presentation: 'preview' as const, + sizeBytes: 3_072, + sizeLabel: '3 KB', + }, ]; const tree = workspaceEntriesToTree('workspace-id', entries); diff --git a/web/src/lib/review/browser-crypto.ts b/web/src/lib/review/browser-crypto.ts index c6244665..2d249c47 100644 --- a/web/src/lib/review/browser-crypto.ts +++ b/web/src/lib/review/browser-crypto.ts @@ -863,11 +863,12 @@ export function buildOwnerSignatureHeader( method: string, urlPath: string, body: Uint8Array, + queryPairs: Array<[string, string]> = [], ): string { if (signingSecret.length !== 32) { throw new Error('owner signing secret must be 32 bytes'); } - const canon = canonicalRequestBytes(method, urlPath, [], body); + const canon = canonicalRequestBytes(method, urlPath, queryPairs, body); try { return base64UrlEncode(ed25519.sign(canon, signingSecret)); } finally { @@ -898,9 +899,10 @@ export function buildAdmissionHeaderV3( method: string, urlPath: string, body: Uint8Array, + queryPairs: Array<[string, string]> = [], ): string { if (admissionKey.length !== 32) throw new Error('admissionKey must be 32 bytes'); - const canon = canonicalRequestBytes(method, urlPath, [], body); + const canon = canonicalRequestBytes(method, urlPath, queryPairs, body); const tag = hmac(sha256, admissionKey, canon); canon.fill(0); return `v3.${scope}.${base64UrlEncode(tag)}`; diff --git a/web/src/lib/review/browser-invite.test.ts b/web/src/lib/review/browser-invite.test.ts index 4f7e8ba3..f9e3fd6d 100644 --- a/web/src/lib/review/browser-invite.test.ts +++ b/web/src/lib/review/browser-invite.test.ts @@ -8,6 +8,7 @@ import { parseInviteUrl, composeInviteUrl, + composeInviteForms, parseAndStripInviteFromUrl, stripFragment, zero, @@ -21,6 +22,7 @@ import { import { aeadOpen, aeadSeal, + deriveRoomId, deriveReadKeysV3, deriveRoomKeys, deriveRoomKeyTreeV3, @@ -254,26 +256,28 @@ defineCase('parseInviteUrl rejects non-/review path on https host', () => { defineCase('composeInviteUrl roundtrips through parseInviteUrl', () => { const secret = fixtureSecret(); - const composed = composeInviteUrl('attn://review', 'room-rt', secret); + const roomId = deriveRoomId(secret); + const composed = composeInviteUrl('attn://review', roomId, secret); assertEq( composed, - `attn://review/room-rt#key=${FIXTURE_KEY_B64URL}`, + `attn://review/${roomId}#key=${FIXTURE_KEY_B64URL}`, 'composed native shape', ); const parsed = parseInviteUrl(composed); - assertEq(parsed.roomId, 'room-rt', 'parsed roomId'); + assertEq(parsed.roomId, roomId, 'parsed roomId'); assertBytesEq(legacySecret(parsed), secret, 'parsed secret'); - const composedHttps = composeInviteUrl('https://attn.dev/review', 'room-rt', secret); + const composedHttps = composeInviteUrl('https://attn.dev/review', roomId, secret); const parsedHttps = parseInviteUrl(composedHttps); - assertEq(parsedHttps.roomId, 'room-rt', 'browser parsed roomId'); + assertEq(parsedHttps.roomId, roomId, 'browser parsed roomId'); assertBytesEq(legacySecret(parsedHttps), secret, 'browser parsed secret'); }); defineCase('composeInviteUrl normalizes trailing slash on base', () => { const secret = fixtureSecret(); - const a = composeInviteUrl('attn://review', 'room-a', secret); - const b = composeInviteUrl('attn://review/', 'room-a', secret); + const roomId = deriveRoomId(secret); + const a = composeInviteUrl('attn://review', roomId, secret); + const b = composeInviteUrl('attn://review/', roomId, secret); assertEq(a, b, 'trailing slash normalized'); }); @@ -290,6 +294,92 @@ defineCase('composeInviteUrl rejects wrong-length roomSecret', () => { ); }); +defineCase('composeInviteForms produces equivalent browser, native, and CLI forms', () => { + const secret = fixtureSecret(); + const forms = composeInviteForms(secret); + assertEq(forms.roomId, deriveRoomId(secret), 'room id derived once'); + assertEq( + forms.browserUrl, + `https://attn.sh/review/${forms.roomId}#key=${FIXTURE_KEY_B64URL}`, + 'production browser form', + ); + assertEq( + forms.nativeUrl, + `attn://review/${forms.roomId}#key=${FIXTURE_KEY_B64URL}`, + 'native form', + ); + assertEq( + forms.cliCommand, + `npx attnmd review join '${forms.nativeUrl}'`, + 'CLI form shell-quotes the native invite', + ); + const browser = parseInviteUrl(forms.browserUrl); + const native = parseInviteUrl(forms.nativeUrl); + assertEq(browser.roomId, native.roomId, 'forms bind the same room'); + assertBytesEq(legacySecret(browser), legacySecret(native), 'forms carry the same secret'); +}); + +defineCase('composeInviteForms accepts staging HTTPS and exact loopback HTTP', () => { + const secret = fixtureSecret(); + assert( + composeInviteForms(secret, 'https://staging.attn.sh/review').browserUrl + .startsWith('https://staging.attn.sh/review/'), + 'staging base accepted', + ); + for (const base of [ + 'http://localhost:5197/review', + 'http://127.0.0.1:5197/review', + 'http://[::1]:5197/review', + ]) { + assert(composeInviteForms(secret, base).browserUrl.startsWith(base), `${base} accepted`); + } +}); + +defineCase('invite composition rejects unsafe bases, paths, ids, and mismatched secrets', () => { + const secret = fixtureSecret(); + const roomId = deriveRoomId(secret); + for (const base of [ + 'http://attn.sh/review', + 'http://localhost.evil/review', + 'https://user:pass@attn.sh/review', + 'https://attn.sh/review?cap=x', + 'https://attn.sh/review#key=x', + 'https://attn.sh/other', + 'javascript:alert(1)', + ]) { + assertThrows( + () => composeInviteForms(secret, base), + (error) => error instanceof InviteParseError && !error.message.includes(FIXTURE_KEY_B64URL), + `unsafe base rejected: ${base}`, + ); + } + assertThrows( + () => composeInviteUrl('attn://review', `${roomId}/extra`, secret), + (error) => error instanceof InviteParseError, + 'path-injected room id rejected', + ); + assertThrows( + () => composeInviteUrl('attn://review', 'different-room', secret), + (error) => error instanceof InviteParseError && /does not match/.test(error.message), + 'room id/secret mismatch rejected', + ); +}); + +defineCase('invite parse diagnostics never echo fragment-bearing input', () => { + const canary = `SECRET-${FIXTURE_KEY_B64URL}`; + for (const input of [ + `ftp://example.test/review/room#key=${canary}`, + `https://[invalid#key=${canary}`, + `https://attn.sh/${canary}/room#key=${FIXTURE_KEY_B64URL}`, + ]) { + assertThrows( + () => parseInviteUrl(input), + (error) => error instanceof InviteParseError && !error.message.includes(canary), + 'secret-free parse error', + ); + } +}); + defineCase('parseAndStripInviteFromUrl strips the fragment via replaceState', () => { const win = makeMockWindow({ origin: 'https://attn.dev', diff --git a/web/src/lib/review/browser-invite.ts b/web/src/lib/review/browser-invite.ts index 54423c67..8c892f28 100644 --- a/web/src/lib/review/browser-invite.ts +++ b/web/src/lib/review/browser-invite.ts @@ -28,6 +28,8 @@ // // cd web && npx tsx src/lib/review/browser-invite.test.ts +import { deriveRoomId } from './browser-crypto'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -57,6 +59,13 @@ export interface ParsedInviteV3 extends ParsedInviteFragmentV3 { export type ParsedInvite = ParsedInviteV2 | ParsedInviteV3; +export interface InviteForms { + roomId: string; + browserUrl: string; + nativeUrl: string; + cliCommand: string; +} + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -83,6 +92,8 @@ const NATIVE_PREFIX = 'attn://review/'; const BROWSER_PATH_PREFIX = '/review/'; const FRAGMENT_KEY_PREFIX = 'key='; const ROOM_SECRET_LEN = 32; +const ROOM_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; +export const DEFAULT_BROWSER_INVITE_BASE = 'https://attn.sh/review'; // --------------------------------------------------------------------------- // Public API @@ -206,12 +217,18 @@ export function composeInviteUrl( if (typeof roomId !== 'string' || roomId.length === 0) { throw new InviteParseError('roomId must be a non-empty string'); } + if (!ROOM_ID_PATTERN.test(roomId)) { + throw new InviteParseError('roomId must contain only base64url characters'); + } if (!(roomSecret instanceof Uint8Array) || roomSecret.length !== ROOM_SECRET_LEN) { throw new InviteParseError( `roomSecret must be a ${ROOM_SECRET_LEN}-byte Uint8Array`, ); } - const trimmedBase = base.endsWith('/') ? base.slice(0, -1) : base; + if (deriveRoomId(roomSecret) !== roomId) { + throw new InviteParseError('roomId does not match roomSecret'); + } + const trimmedBase = validateInviteBase(base); const encodedKey = base64UrlEncode(roomSecret); return `${trimmedBase}/${roomId}#${FRAGMENT_KEY_PREFIX}${encodedKey}`; } @@ -312,6 +329,24 @@ function decodeGrant(value: string): Uint8Array { return decoded; } +/** Build every public invite representation from one room secret. */ +export function composeInviteForms( + roomSecret: Uint8Array, + browserBase = DEFAULT_BROWSER_INVITE_BASE, +): InviteForms { + if (!(roomSecret instanceof Uint8Array) || roomSecret.length !== ROOM_SECRET_LEN) { + throw new InviteParseError(`roomSecret must be a ${ROOM_SECRET_LEN}-byte Uint8Array`); + } + const roomId = deriveRoomId(roomSecret); + const nativeUrl = composeInviteUrl('attn://review', roomId, roomSecret); + const browserUrl = composeInviteUrl(browserBase, roomId, roomSecret); + return { + roomId, + browserUrl, + nativeUrl, + cliCommand: `npx attnmd review join '${nativeUrl}'`, + }; +} /** * Overwrite a secret buffer with zeros. JS has no real way to guarantee a * value is purged from memory (the runtime may have copied it), but @@ -391,12 +426,10 @@ function splitInvite(url: string): SplitResult { try { parsed = new URL(url); } catch { - throw new InviteParseError(`malformed URL: ${url}`); + throw new InviteParseError('malformed invite URL'); } if (!parsed.pathname.startsWith(BROWSER_PATH_PREFIX)) { - throw new InviteParseError( - `path must start with ${BROWSER_PATH_PREFIX} (got ${parsed.pathname})`, - ); + throw new InviteParseError(`path must start with ${BROWSER_PATH_PREFIX}`); } const rest = parsed.pathname.slice(BROWSER_PATH_PREFIX.length); // `URL.hash` includes the leading `#` (or is empty). Normalize to match @@ -411,7 +444,7 @@ function splitInvite(url: string): SplitResult { } throw new InviteParseError( - `unsupported scheme — expected attn://review/ or https://…/review/ (got: ${truncate(url, 40)})`, + 'unsupported scheme — expected attn://review/ or https://…/review/', ); } @@ -426,8 +459,36 @@ function splitFragment(rest: string): SplitResult { }; } -function truncate(s: string, n: number): string { - return s.length > n ? `${s.slice(0, n)}…` : s; +function validateInviteBase(base: string): string { + const trimmed = base.endsWith('/') ? base.slice(0, -1) : base; + if (trimmed === 'attn://review') return trimmed; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new InviteParseError('browser invite base must be an absolute URL'); + } + const loopback = + parsed.hostname === 'localhost' + || parsed.hostname === '127.0.0.1' + || parsed.hostname === '[::1]'; + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) { + throw new InviteParseError('browser invite base must use HTTPS or exact loopback HTTP'); + } + if ( + parsed.username.length > 0 + || parsed.password.length > 0 + || parsed.search.length > 0 + || parsed.hash.length > 0 + ) { + throw new InviteParseError('browser invite base cannot contain credentials, query, or fragment'); + } + if (parsed.pathname.replace(/\/+$/u, '') !== '/review') { + throw new InviteParseError('browser invite base path must be /review'); + } + parsed.pathname = '/review'; + return parsed.toString().replace(/\/$/u, ''); } // --------------------------------------------------------------------------- diff --git a/web/src/lib/review/browser-owner-bootstrap.test.ts b/web/src/lib/review/browser-owner-bootstrap.test.ts index 7801cda6..3dbb8b5c 100644 --- a/web/src/lib/review/browser-owner-bootstrap.test.ts +++ b/web/src/lib/review/browser-owner-bootstrap.test.ts @@ -2,14 +2,19 @@ import { ed25519 } from '@noble/curves/ed25519.js'; import { OwnerBootstrapError, createOwnedRoom, + createOwnedRoomV3, defaultOwnerPolicy, deleteOwnedRoom, + deleteOwnedRoomV3, } from './browser-owner-bootstrap'; import { base64UrlDecode, buildAdmissionHeader, + buildAdmissionHeaderV3, buildOwnerSignatureHeader, deriveRoomId, + deriveRoomIdV3, + deriveRoomKeyTreeV3, deriveRoomKeys, } from './browser-crypto'; import { generateBrowserIdentity } from './browser-session'; @@ -71,7 +76,7 @@ function stubRelay(handlers: { ), body: typeof init?.body === 'string' ? init.body : '', }); - if (method === 'POST' && /\/v2\/rooms\/[^/]+$/u.test(url)) { + if (method === 'POST' && /\/v[23]\/rooms\/[^/]+$/u.test(url)) { const status = handlers.createStatus ?? 201; return new Response( JSON.stringify({ @@ -166,6 +171,74 @@ defineCase('create sends the exact native wire shape with verifiable headers', a assertEqual(result.created, true, '201 => created'); }); +defineCase('v3 create uses split read/write admission and room-bound grants', async () => { + const { fetchImpl, requests } = stubRelay({}); + const result = await createOwnedRoomV3({ relayUrl: RELAY, fetchImpl, mintPow }); + assertEqual(requests.length, 2, 'v3 create then register'); + const create = requests[0]!; + const body = JSON.parse(create.body); + const path = `/v3/rooms/${result.roomId}`; + assertEqual(create.url, `${RELAY}${path}`, 'v3 create route'); + assertEqual(body.v, 3, 'v3 body'); + assertEqual(deriveRoomIdV3(result.roomSecret), result.roomId, 'v3 room id'); + const keys = deriveRoomKeyTreeV3(result.roomSecret); + const bodyBytes = new TextEncoder().encode(create.body); + assertEqual( + create.headers['Attn-Admission'], + buildAdmissionHeaderV3(keys.writeAdmissionKey, 'write', 'POST', path, bodyBytes), + 'v3 write admission binds create', + ); + assert(body.readAdmissionKey !== body.writeAdmissionKey, 'split admission leaves differ'); + assertEqual(base64UrlDecode(result.commentGrantSignature).length, 64, 'comment grant bytes'); + assertEqual(base64UrlDecode(result.suggestGrantSignature).length, 64, 'suggest grant bytes'); + const register = requests[1]!; + assert(register.url.includes('/v3/rooms/'), 'v3 device route'); + assert(register.headers['Attn-Admission']?.startsWith('v3.write.'), 'v3 register write admission'); +}); + +defineCase('v3 delete signs and write-authenticates the exact route', async () => { + const identity = generateBrowserIdentity(); + const roomSecret = new Uint8Array(32).fill(31); + const roomId = deriveRoomIdV3(roomSecret); + const keys = deriveRoomKeyTreeV3(roomSecret); + const { fetchImpl, requests } = stubRelay({ deleteStatus: 204 }); + const stopped = await deleteOwnedRoomV3({ + relayUrl: RELAY, + roomId, + identity, + writeAdmissionKey: keys.writeAdmissionKey, + fetchImpl, + mintPow, + }); + assertEqual(stopped, true, 'v3 delete accepted'); + const request = requests[0]!; + const path = `/v3/rooms/${roomId}`; + assertEqual(request.url, `${RELAY}${path}`, 'v3 delete route'); + assertEqual( + request.headers['Attn-Admission'], + buildAdmissionHeaderV3(keys.writeAdmissionKey, 'write', 'DELETE', path, new Uint8Array(0)), + 'v3 delete admission', + ); +}); + +defineCase('explicit long session carries the relay 7-day opt-in', async () => { + const now = 1_700_000_000_000; + const { fetchImpl, requests } = stubRelay({}); + const policy = defaultOwnerPolicy(now); + policy.expiresAt = now + 7 * 24 * 60 * 60 * 1000; + await createOwnedRoom({ + relayUrl: RELAY, + fetchImpl, + mintPow, + now: () => now, + policy, + longSession: true, + }); + const body = JSON.parse(requests[0]!.body); + assertEqual(body.policy.longSession, true, 'long-session wire flag'); + assertEqual(body.policy.expiresAt, policy.expiresAt, '7-day expiry request'); +}); + defineCase('rejoin (200) is idempotent and does not roll back', async () => { const { fetchImpl, requests } = stubRelay({ createStatus: 200 }); const result = await createOwnedRoom({ relayUrl: RELAY, fetchImpl, mintPow }); @@ -247,6 +320,22 @@ defineCase('deleteOwnedRoom signs the canonical DELETE verifiably', async () => ); }); +defineCase('deleteOwnedRoom treats already absent or expired rooms as stopped', async () => { + for (const status of [404, 410]) { + const { fetchImpl } = stubRelay({ deleteStatus: status }); + const identity = generateBrowserIdentity(); + const deleted = await deleteOwnedRoom({ + relayUrl: RELAY, + roomId: deriveRoomId(new Uint8Array(32).fill(status)), + identity, + admissionKey: new Uint8Array(32).fill(3), + fetchImpl, + mintPow, + }); + assertEqual(deleted, true, `${status} is idempotently stopped`); + } +}); + defineCase('default policy mirrors the native defaults', () => { const policy = defaultOwnerPolicy(1_000); assertEqual(policy.maxSnapshotBytes, 5 * 1024 * 1024, 'snapshot cap'); diff --git a/web/src/lib/review/browser-owner-bootstrap.ts b/web/src/lib/review/browser-owner-bootstrap.ts index 7916569d..41601ab2 100644 --- a/web/src/lib/review/browser-owner-bootstrap.ts +++ b/web/src/lib/review/browser-owner-bootstrap.ts @@ -22,17 +22,24 @@ import { base64UrlEncode, buildAdmissionHeader, + buildAdmissionHeaderV3, buildOwnerSignatureHeader, deriveRoomId, + deriveRoomIdV3, + deriveRoomKeyTreeV3, deriveRoomKeys, signingKeyId, type RoomKeys, + type RoomKeyTreeV3, } from './browser-crypto'; +import { ed25519 } from '@noble/curves/ed25519.js'; import { mintBrowserPowInWorker, type BrowserPowInputs } from './browser-pow'; import { buildRegisterDeviceBody, + canonicalDeviceGrantV3, generateBrowserIdentity, type BrowserDeviceIdentity, + type RegisterDeviceBodyV3, } from './browser-session'; import { validateBrowserRelayUrl } from './browser-relay-url'; import type { RoomPolicy } from './browser-ws'; @@ -71,7 +78,7 @@ export function defaultOwnerPolicy(createdAtMs: number): RoomPolicy { } /** Wire policy exactly as bootstrap.rs::WirePolicy serializes it. */ -function wirePolicy(policy: RoomPolicy): Record { +function wirePolicy(policy: RoomPolicy, longSession: boolean): Record { return { mode: policy.mode, maxPeers: policy.maxPeers, @@ -80,7 +87,7 @@ function wirePolicy(policy: RoomPolicy): Record { maxEvents: policy.maxEvents, expiresAt: policy.expiresAt, idleTimeoutMs: 60 * 60 * 1000, - longSession: false, + longSession, powBits: OWNER_BOOTSTRAP_POW_DIFFICULTY, deleteEventsAfterOwnerAck: policy.deleteEventsAfterOwnerAck, allowBrowser: policy.allowBrowser, @@ -99,9 +106,23 @@ export interface OwnedRoomBootstrap { created: boolean; } +/** Browser-owned ordinary v3 epoch room used behind a stable durable share. */ +export interface OwnedRoomBootstrapV3 { + roomId: string; + roomSecret: Uint8Array; + keys: RoomKeyTreeV3; + identity: BrowserDeviceIdentity; + policy: RoomPolicy; + commentGrantSignature: string; + suggestGrantSignature: string; + created: boolean; +} + export interface CreateOwnedRoomOptions { relayUrl: string; policy?: RoomPolicy; + /** Relay 7-day tier. Must be explicit; ordinary rooms remain bounded to 24h. */ + longSession?: boolean; now?: () => number; fetchImpl?: typeof fetch; /** Injectable PoW minter (tests); defaults to the worker miner. */ @@ -130,7 +151,7 @@ export async function createOwnedRoom(options: CreateOwnedRoomOptions): Promise< const path = `/v2/rooms/${roomId}`; const body = { v: 2, - policy: wirePolicy(policy), + policy: wirePolicy(policy, options.longSession === true), ownerSigningKey: base64UrlEncode(identity.signingPublic), admissionKey: base64UrlEncode(keys.admissionKey), }; @@ -189,6 +210,90 @@ export async function createOwnedRoom(options: CreateOwnedRoomOptions): Promise< return { roomId, roomSecret, keys, identity, policy, created }; } +/** + * Create or rejoin a split-capability v3 room and register its browser owner. + * The returned grant signatures are bound to this exact room and owner key; + * callers place them only inside the comment/suggest durable-share bundles. + */ +export async function createOwnedRoomV3( + options: CreateOwnedRoomOptions, +): Promise { + const relay = validateBrowserRelayUrl(options.relayUrl); + const fetchImpl = options.fetchImpl ?? fetch; + const now = options.now ?? Date.now; + const mintPow = options.mintPow ?? ((input, signal) => mintBrowserPowInWorker(input, { signal })); + const abort = options.signal ?? new AbortController().signal; + const roomSecret = options.roomSecret ?? crypto.getRandomValues(new Uint8Array(32)); + const roomId = deriveRoomIdV3(roomSecret); + const keys = deriveRoomKeyTreeV3(roomSecret); + const identity = options.identity ?? generateBrowserIdentity(); + const policy = options.policy ?? defaultOwnerPolicy(now()); + const path = `/v3/rooms/${roomId}`; + const body = { + v: 3, + policy: wirePolicy(policy, options.longSession === true), + ownerSigningKey: base64UrlEncode(identity.signingPublic), + readAdmissionKey: base64UrlEncode(keys.readKeys.readAdmissionKey), + writeAdmissionKey: base64UrlEncode(keys.writeAdmissionKey), + }; + const bodyJson = JSON.stringify(body); + const bodyBytes = new TextEncoder().encode(bodyJson); + const ownerSigningKeyId = signingKeyId(identity.signingPublic); + const pow = await mintPow( + { roomId, deviceId: ownerSigningKeyId, method: 'POST', path, difficulty: OWNER_BOOTSTRAP_POW_DIFFICULTY }, + abort, + ); + const createResponse = await fetchImpl(`${relay}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json; charset=utf-8', + 'Attn-Admission': buildAdmissionHeaderV3( + keys.writeAdmissionKey, + 'write', + 'POST', + path, + bodyBytes, + ), + 'Attn-Owner-Signature': buildOwnerSignatureHeader(identity.signingSecret, 'POST', path, bodyBytes), + 'Attn-PoW': pow, + }, + body: bodyJson, + }); + if (createResponse.status !== 200 && createResponse.status !== 201) { + const message = await createResponse.text().catch(() => ''); + throw new OwnerBootstrapError('create', `room create failed: ${message.slice(0, 200)}`, createResponse.status); + } + const created = createResponse.status === 201; + try { + await registerOwnerDeviceV3({ relay, roomId, keys, identity, fetchImpl, mintPow, abort }); + } catch (error) { + if (created) { + await deleteOwnedRoomV3({ + relayUrl: relay, + roomId, + identity, + writeAdmissionKey: keys.writeAdmissionKey, + fetchImpl, + mintPow, + }).catch(() => undefined); + } + throw error; + } + const signGrant = (tier: 'comment' | 'suggest'): string => base64UrlEncode( + ed25519.sign(canonicalDeviceGrantV3(roomId, tier), identity.signingSecret), + ); + return { + roomId, + roomSecret, + keys, + identity, + policy, + commentGrantSignature: signGrant('comment'), + suggestGrantSignature: signGrant('suggest'), + created, + }; +} + async function registerOwnerDevice(input: { relay: string; roomId: string; @@ -231,6 +336,100 @@ async function registerOwnerDevice(input: { } } +async function registerOwnerDeviceV3(input: { + relay: string; + roomId: string; + keys: RoomKeyTreeV3; + identity: BrowserDeviceIdentity; + fetchImpl: typeof fetch; + mintPow: (pow: OwnerPowRequest, signal: AbortSignal) => Promise; + abort: AbortSignal; +}): Promise { + const body = buildRegisterDeviceBody(input.identity, 'owner'); + const bodyJson = JSON.stringify(body); + const bodyBytes = new TextEncoder().encode(bodyJson); + const path = `/v3/rooms/${input.roomId}/devices`; + const pow = await input.mintPow({ + roomId: input.roomId, + deviceId: input.identity.deviceId, + method: 'POST', + path, + difficulty: OWNER_BOOTSTRAP_POW_DIFFICULTY, + }, input.abort); + const response = await input.fetchImpl(`${input.relay}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json; charset=utf-8', + 'Attn-Admission': buildAdmissionHeaderV3( + input.keys.writeAdmissionKey, + 'write', + 'POST', + path, + bodyBytes, + ), + 'Attn-PoW': pow, + }, + body: bodyJson, + }); + if (response.status !== 200 && response.status !== 204) { + const message = await response.text().catch(() => ''); + throw new OwnerBootstrapError('register', `owner registration failed: ${message.slice(0, 200)}`, response.status); + } +} + +/** Forward one already self-signed, owner-granted visitor registration. */ +export async function registerFrozenReviewerDeviceV3(input: { + relayUrl: string; + roomId: string; + writeAdmissionKey: Uint8Array; + registration: RegisterDeviceBodyV3; + fetchImpl?: typeof fetch; + mintPow?: (pow: OwnerPowRequest, signal: AbortSignal) => Promise; + signal?: AbortSignal; +}): Promise { + const relay = validateBrowserRelayUrl(input.relayUrl); + const fetchImpl = input.fetchImpl ?? fetch; + const mintPow = input.mintPow ?? ((request, signal) => mintBrowserPowInWorker(request, { signal })); + const abort = input.signal ?? new AbortController().signal; + const path = `/v3/rooms/${input.roomId}/devices`; + const body = JSON.stringify(input.registration); + const bytes = new TextEncoder().encode(body); + try { + const pow = await mintPow({ + roomId: input.roomId, + deviceId: input.registration.deviceId, + method: 'POST', + path, + difficulty: OWNER_BOOTSTRAP_POW_DIFFICULTY, + }, abort); + const response = await fetchImpl(`${relay}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json; charset=utf-8', + 'Attn-Admission': buildAdmissionHeaderV3( + input.writeAdmissionKey, + 'write', + 'POST', + path, + bytes, + ), + 'Attn-PoW': pow, + }, + body, + signal: abort, + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + if (response.status !== 200 && response.status !== 204) { + throw new OwnerBootstrapError('register', `frozen reviewer registration failed (${response.status})`, response.status); + } + } finally { + bytes.fill(0); + } +} + /** * Owner-signed room teardown. The relay requires all three proofs: admission * HMAC (URL possession), PoW bound to the REGISTERED deleting device, and @@ -275,5 +474,49 @@ export async function deleteOwnedRoom(input: { ), }, }); - return response.status === 200 || response.status === 204; + // Stop is idempotent: an authenticated owner asking to delete a room that + // is already absent/expired has reached the same authoritative outcome. + return response.status === 200 + || response.status === 204 + || response.status === 404 + || response.status === 410; +} + +export async function deleteOwnedRoomV3(input: { + relayUrl: string; + roomId: string; + identity: BrowserDeviceIdentity; + writeAdmissionKey: Uint8Array; + fetchImpl?: typeof fetch; + mintPow?: (pow: OwnerPowRequest, signal: AbortSignal) => Promise; + signal?: AbortSignal; +}): Promise { + const relay = validateBrowserRelayUrl(input.relayUrl); + const fetchImpl = input.fetchImpl ?? fetch; + const mintPow = input.mintPow ?? ((request, signal) => mintBrowserPowInWorker(request, { signal })); + const abort = input.signal ?? new AbortController().signal; + const path = `/v3/rooms/${input.roomId}`; + const empty = new Uint8Array(0); + const pow = await mintPow({ + roomId: input.roomId, + deviceId: input.identity.deviceId, + method: 'DELETE', + path, + difficulty: OWNER_BOOTSTRAP_POW_DIFFICULTY, + }, abort); + const response = await fetchImpl(`${relay}${path}`, { + method: 'DELETE', + headers: { + 'Attn-Admission': buildAdmissionHeaderV3( + input.writeAdmissionKey, + 'write', + 'DELETE', + path, + empty, + ), + 'Attn-PoW': pow, + 'Attn-Owner-Signature': buildOwnerSignatureHeader(input.identity.signingSecret, 'DELETE', path, empty), + }, + }); + return response.status === 200 || response.status === 204 || response.status === 404 || response.status === 410; } diff --git a/web/src/lib/review/browser-owner-workspace-runtime.test.ts b/web/src/lib/review/browser-owner-workspace-runtime.test.ts index 12b997b1..47e55eb9 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.test.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.test.ts @@ -1,10 +1,13 @@ import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { sha256 } from '@noble/hashes/sha2.js'; import { assembleBrowserEvent, type AssembledBrowserEvent } from './browser-envelope'; import { base64UrlEncode, contentHash, deriveRoomId, + deriveRoomIdV3, + deriveRoomKeyTreeV3, deriveRoomKeys, } from './browser-crypto'; import { @@ -12,6 +15,7 @@ import { type BrowserOwnerWorkspaceAuthority, type BrowserOwnerWorkspaceRuntimeOptions, } from './browser-owner-workspace-runtime'; +import type { CreateOwnedRoomOptions, OwnedRoomBootstrapV3 } from './browser-owner-bootstrap'; import type { BrowserOwnerAuthorityFile, BrowserOwnerAuthorityOptions, @@ -21,7 +25,23 @@ import type { } from './browser-owner-authority'; import { BrowserStorage } from './browser-storage'; import { generateBrowserIdentity } from './browser-session'; +import { + publishBrowserSnapshots, + type PublishBrowserSnapshotsOptions, +} from './browser-snapshot-publisher'; import { inviteCapabilityFrom } from './browser-workspace-share'; +import { + BrowserShareOwnerRelayError, + digestShareSnapshotManifest, + type BrowserShareRelayRecord, + type BrowserShareUpsertRequest, + type ManagedShareSnapshotRef, +} from './browser-share-owner'; +import type { + BrowserShareOwnerRelayPort, + BrowserWorkspaceShareOutbox, + BrowserWorkspaceShareRequest, +} from './browser-workspace-sharing'; import type { MailboxEnvelope, RoomPolicy } from './browser-ws'; import type { Anchor, ReviewEvent } from '../types'; @@ -79,6 +99,132 @@ function opaque(fill: number): string { return base64UrlEncode(new Uint8Array(16).fill(fill)); } +function bootstrapFromOptions(options: CreateOwnedRoomOptions): OwnedRoomBootstrapV3 { + assert(options.roomSecret, 'sharing coordinator supplies its prepared room secret'); + assert(options.identity, 'sharing coordinator supplies its prepared owner identity'); + assert(options.policy, 'sharing coordinator supplies its prepared room policy'); + const roomSecret = new Uint8Array(options.roomSecret); + return { + roomId: deriveRoomIdV3(roomSecret), + roomSecret, + keys: deriveRoomKeyTreeV3(roomSecret), + identity: options.identity, + policy: options.policy, + commentGrantSignature: base64UrlEncode(new Uint8Array(64).fill(3)), + suggestGrantSignature: base64UrlEncode(new Uint8Array(64).fill(5)), + created: true, + }; +} + +class MemoryShareRelay implements BrowserShareOwnerRelayPort { + private record: BrowserShareRelayRecord | null = null; + constructor(private readonly shareId: string) {} + async upsert(request: BrowserShareUpsertRequest): Promise { + this.record = { v: 3, shareId: this.shareId, ownerSigningKey: request.ownerSigningKey, + epoch: request.epoch, revision: request.revision, + ...(request.currentRoomId === null ? {} : { currentRoomId: request.currentRoomId }), + snapshots: structuredClone(request.snapshots), placeholders: [], + manifestDigest: digestShareSnapshotManifest(request.snapshots), updatedAt: 1_800_000_000_000, + expiresAt: 1_900_000_000_000, mailbox: { count: 0, bytes: 0, latestSeq: 0 } }; + return structuredClone(this.record); + } + async fetchWithViewCapability(): Promise { + if (!this.record) throw new BrowserShareOwnerRelayError(404, 'fetch'); + return structuredClone(this.record); + } + async uploadSnapshot(fileId: string, snapshotId: string, ciphertext: Uint8Array): Promise { + assert(this.record, 'dark share exists'); + const ref = { fileId, snapshotId, ciphertextBytes: ciphertext.length, + ciphertextSha256: base64UrlEncode(sha256(ciphertext)), uploadedAt: 1_800_000_000_001 }; + const snapshots = [...this.record.snapshots.filter(value => value.fileId !== fileId), ref] + .sort((a, b) => a.fileId.localeCompare(b.fileId)); + this.record = { ...this.record, snapshots, revision: this.record.revision + 1, + manifestDigest: digestShareSnapshotManifest(snapshots) }; + return ref; + } + async deleteSnapshot(fileId: string): Promise { + if (!this.record) return; + const snapshots = this.record.snapshots.filter(value => value.fileId !== fileId); + this.record = { ...this.record, snapshots, revision: this.record.revision + 1, + manifestDigest: digestShareSnapshotManifest(snapshots) }; + } + async fetchMailbox(): Promise { throw new Error('empty mailbox must not be fetched'); } + async ackMailbox(): Promise { throw new Error('empty mailbox must not be ACKed'); } + async revoke(): Promise { this.record = null; } +} + +function memoryShareRelayFactory() { + let relay: MemoryShareRelay | null = null; + return (options: { shareId: string }) => (relay ??= new MemoryShareRelay(options.shareId)); +} + +const testIndexBuilder = async (markdown: Uint8Array) => ({ + docHash: base64UrlEncode(sha256(markdown)), canonicalEncoding: 'utf8-bytes' as const, + lineCount: new TextDecoder().decode(markdown).split('\n').length, blocks: [], headings: [], +}); + +class AckingShareOutbox implements BrowserWorkspaceShareOutbox { + readonly envelopes: MailboxEnvelope[] = []; + + constructor( + private readonly storage: BrowserStorage, + private readonly roomId: string, + private readonly events: string[], + private readonly failFlush = false, + ) {} + + async initialize(): Promise { + this.events.push('outbox-initialize'); + } + + async enqueueBatchDurably(envelopes: readonly MailboxEnvelope[]): Promise { + for (const envelope of envelopes) { + const existing = this.envelopes.find((item) => item.envelopeId === envelope.envelopeId); + if (!existing) this.envelopes.push(structuredClone(envelope)); + } + this.events.push('outbox-adopt'); + return envelopes.length; + } + + async flushNow(): Promise { + this.events.push('outbox-flush'); + if (this.failFlush) throw new Error('relay offline'); + await this.storage.acknowledge( + this.roomId, + this.envelopes, + this.envelopes.map((envelope, index) => ({ + envelopeId: envelope.envelopeId, + serverSeq: index + 1, + })), + ); + } + + close(): void { + this.events.push('outbox-close'); + } +} + +function deterministicRandom(): (length: number) => Uint8Array { + let counter = 1; + return (length) => new Uint8Array(length).fill(counter++); +} + +function shareRequest(): BrowserWorkspaceShareRequest { + return { + relayUrl: 'https://relay.example', + browserReviewBase: 'https://attn.example/review', + scopeKind: 'file', + paths: ['notes.md'], + }; +} + +function snapshotPublisher(options: PublishBrowserSnapshotsOptions): Promise { + return publishBrowserSnapshots({ + ...options, + indexBuilder: testIndexBuilder, + }); +} + async function seedLocal(storage: BrowserStorage, workspaceId: string, text = 'hello') { return storage.workspaces.createWorkspace({ workspaceId, @@ -174,6 +320,7 @@ class FakeAuthority implements BrowserOwnerWorkspaceAuthority { } async close(): Promise { + this.events.push('authority-close'); this.sessionStorage?.close(); this.sessionStorage = null; this.state = authorityState('closed', null); @@ -357,6 +504,198 @@ defineCase('local heartbeat loss becomes passive and close cannot release the ta storage.close(); }); +defineCase('ensureShare activates authority on the runtime-owned lease without reacquiring', async () => { + const now = 1_720_000_000_000; + const storage = await openStorage(() => now); + const workspaceId = 'share-activate-attached-lease'; + await seedLocal(storage, workspaceId); + const events: string[] = []; + const attachedLeases: BrowserOwnerAuthorityOptions['attachedLease'][] = []; + const randomBytes = deterministicRandom(); + const runtime = new BrowserOwnerWorkspaceRuntime(runtimeOptions(storage, workspaceId, { + now: () => now, + authorityFactory: (options) => { + attachedLeases.push(options.attachedLease); + return new FakeAuthority(options, storage, events); + }, + sharing: { + now: () => now, + randomBytes, + createRoom: async (options) => bootstrapFromOptions(options), + publish: snapshotPublisher, + indexBuilder: testIndexBuilder, + shareRelayFactory: memoryShareRelayFactory(), + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingShareOutbox(outboxStorage, credentials.roomId, events), + }, + })); + await runtime.start(); + const ownedFence = runtime.fence; + assert(ownedFence, 'runtime owns a workspace lease before sharing'); + + const view = await runtime.ensureShare(shareRequest()); + assert(view.invite, 'published share exposes its invite'); + equal(attachedLeases.length, 1, 'one authority instance activated'); + equal( + attachedLeases[0]?.fencingToken, + ownedFence.fencingToken, + 'authority receives the existing runtime fence', + ); + equal(runtime.fence?.fencingToken, ownedFence.fencingToken, 'share activation preserves fence token'); + equal(runtime.fence?.holderId, ownedFence.holderId, 'share activation preserves lease holder'); + equal(runtime.getState().roomId, view.roomId, 'active runtime exposes published room'); + equal(runtime.getState().liveEditingAvailable, true, 'live authority becomes available'); + + const competing = storage.leases({ channel: null }); + equal( + await competing.acquire(workspaceId, 'competing-tab'), + null, + 'runtime continues to own the sole workspace lease', + ); + competing.close(); + await runtime.close(); + storage.close(); +}); + +defineCase('ensureShare resumes persisted pending ciphertext before activating authority', async () => { + const now = 1_720_000_000_000; + const storage = await openStorage(() => now); + const workspaceId = 'share-pending-runtime-resume'; + await seedLocal(storage, workspaceId); + const events: string[] = []; + const outboxes: AckingShareOutbox[] = []; + let createCalls = 0; + let publishCalls = 0; + const sharing: NonNullable = { + now: () => now, + randomBytes: deterministicRandom(), + createRoom: async (options) => { + createCalls += 1; + return bootstrapFromOptions(options); + }, + publish: async (options) => { + publishCalls += 1; + return snapshotPublisher(options); + }, + indexBuilder: testIndexBuilder, + shareRelayFactory: memoryShareRelayFactory(), + outboxFactory: ({ storage: outboxStorage, credentials }) => { + const outbox = new AckingShareOutbox( + outboxStorage, + credentials.roomId, + events, + outboxes.length === 0, + ); + outboxes.push(outbox); + return outbox; + }, + }; + const first = new BrowserOwnerWorkspaceRuntime(runtimeOptions(storage, workspaceId, { + holderId: 'pending-first-tab', + now: () => now, + sharing, + })); + await first.start(); + let failed = false; + try { + await first.ensureShare(shareRequest()); + } catch (error) { + failed = error instanceof Error && error.message === 'relay offline'; + } + assert(failed, 'initial publication stops at the simulated relay outage'); + const pending = await first.inspectShare('https://attn.example/review'); + assert(pending, 'pending ownership remains inspectable'); + equal(pending.publication, 'pending', 'failed publication remains pending'); + equal(pending.invite, null, 'pending publication does not expose an invite'); + equal(first.getState().roomId, null, 'authority does not start before promotion'); + const exactPendingBatch = JSON.stringify(outboxes[0]?.envelopes); + await first.close(); + + let authorityStarts = 0; + const second = new BrowserOwnerWorkspaceRuntime(runtimeOptions(storage, workspaceId, { + holderId: 'pending-resume-tab', + now: () => now, + sharing, + authorityFactory: (options) => { + authorityStarts += 1; + return new FakeAuthority(options, storage, events); + }, + })); + await second.start(); + const resumed = await second.inspectShare('https://attn.example/review'); + assert(resumed, 'route startup promotes the recoverable share'); + equal(createCalls, 2, 'persisted resume idempotently rejoins the same relay room'); + equal(publishCalls, 1, 'persisted resume does not assemble fresh ciphertext'); + equal(JSON.stringify(outboxes[1]?.envelopes), exactPendingBatch, 'resume adopts exact pending batch'); + equal(resumed.roomId, pending.roomId, 'resume keeps prepared room identity'); + equal(resumed.capId, pending.capId, 'resume keeps prepared capability identity'); + assert(resumed.invite, 'invite appears after resumed publication promotes'); + equal(authorityStarts, 1, 'authority starts after automatic resumed promotion'); + equal(second.getState().liveEditingAvailable, true, 'resumed runtime activates live authority'); + await second.close(); + storage.close(); +}); + +defineCase('stopShare tears down authority, resets runtime state, and recreates fresh ownership', async () => { + const now = 1_720_000_000_000; + const storage = await openStorage(() => now); + const workspaceId = 'share-stop-recreate-runtime'; + await seedLocal(storage, workspaceId); + const events: string[] = []; + let authorityInstances = 0; + let deleteCalls = 0; + const runtime = new BrowserOwnerWorkspaceRuntime(runtimeOptions(storage, workspaceId, { + now: () => now, + authorityFactory: (options) => { + authorityInstances += 1; + return new FakeAuthority(options, storage, events); + }, + sharing: { + now: () => now, + randomBytes: deterministicRandom(), + createRoom: async (options) => bootstrapFromOptions(options), + deleteRoom: async () => { + deleteCalls += 1; + events.push('relay-delete'); + return true; + }, + publish: snapshotPublisher, + indexBuilder: testIndexBuilder, + shareRelayFactory: memoryShareRelayFactory(), + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingShareOutbox(outboxStorage, credentials.roomId, events), + }, + })); + await runtime.start(); + const first = await runtime.ensureShare(shareRequest()); + equal(authorityInstances, 1, 'first share starts one authority'); + + await runtime.stopShare(); + equal(deleteCalls, 1, 'stop performs one owner-authorized relay deletion'); + assert( + events.indexOf('relay-delete') < events.indexOf('authority-close'), + 'relay deletion succeeds before local authority shuts down', + ); + equal((await storage.shares.listShares(workspaceId)).length, 0, 'stop erases local capability'); + equal(runtime.getState().status, 'active', 'local-only runtime remains active after stop'); + equal(runtime.getState().leaseRole, 'owner', 'runtime retains workspace lease after stop'); + equal(runtime.getState().writable, true, 'local authoring remains writable after stop'); + equal(runtime.getState().liveEditingAvailable, false, 'live authority is unavailable after stop'); + equal(runtime.getState().roomId, null, 'stopped room identity is cleared'); + equal(runtime.getState().capId, null, 'stopped capability identity is cleared'); + equal(runtime.getState().bindings.length, 0, 'stopped share bindings are cleared'); + equal(runtime.getState().authority, null, 'stopped authority state is cleared'); + + const recreated = await runtime.ensureShare(shareRequest()); + assert(recreated.roomId !== first.roomId, 'recreate mints a fresh room secret and room ID'); + assert(recreated.capId !== first.capId, 'recreate mints a fresh capability ID'); + equal(authorityInstances, 2, 'recreate starts a fresh authority instance'); + equal(runtime.getState().roomId, recreated.roomId, 'recreated runtime exposes new room'); + equal(runtime.getState().liveEditingAvailable, true, 'recreated authority is live'); + await runtime.close(); + storage.close(); +}); + defineCase('accepted action commits before snapshot publication and authority reseed', async () => { const now = 1_700_000_000_000; const storage = await openStorage(() => now); diff --git a/web/src/lib/review/browser-owner-workspace-runtime.ts b/web/src/lib/review/browser-owner-workspace-runtime.ts index 845cca90..1afe092e 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.ts @@ -30,6 +30,7 @@ import { } from './browser-review-actions'; import { ownerCredentialsFromInviteCapability, + ownerCredentialsV3FromInviteCapability, type BrowserOwnerCredentials, type BrowserSessionOptions, } from './browser-session'; @@ -47,6 +48,12 @@ import type { CommittedRevision, CommitRevisionInput } from './browser-workspace import type { LeaseHandle, WorkspaceLeaseManagerOptions } from './browser-workspace-lease'; import type { CollabController } from '../prosemirror/collab-controller'; import type { BrowserReviewTerminalPort } from './browser-review-actions'; +import { + BrowserWorkspaceSharingCoordinator, + type BrowserWorkspaceShareRequest, + type BrowserWorkspaceShareView, + type BrowserWorkspaceSharingDependencies, +} from './browser-workspace-sharing'; export type BrowserOwnerWorkspaceRuntimeStatus = | 'starting' @@ -107,6 +114,8 @@ export interface BrowserOwnerWorkspaceRuntimeOptions { authorityFactory?: (options: BrowserOwnerAuthorityOptions) => BrowserOwnerWorkspaceAuthority; /** Test seam. Production always calls the canonical snapshot publisher. */ publisher?: (options: PublishBrowserSnapshotsOptions) => Promise; + /** Initial share/stop seams; production uses the canonical coordinator. */ + sharing?: BrowserWorkspaceSharingDependencies; schedule?: (callback: () => void, delayMs: number) => unknown; cancelScheduled?: (handle: unknown) => void; pagehideTarget?: { @@ -133,6 +142,15 @@ export type BrowserOwnerWorkspaceRuntimeSubscriber = ( state: BrowserOwnerWorkspaceRuntimeState, ) => void; +interface DiscoveredPublishedShare { + share: ShareRecordView; + rootKey: CryptoKey; + credentials: BrowserOwnerCredentials; + bindings: BrowserOwnerAuthorityFile[]; + pendingPublication: boolean; + localHeadsMoved: boolean; +} + export class BrowserOwnerWorkspaceRuntime { private readonly options: BrowserOwnerWorkspaceRuntimeOptions; private readonly leaseManager; @@ -253,6 +271,7 @@ export class BrowserOwnerWorkspaceRuntime { this.pagehideTarget?.addEventListener('pagehide', this.pagehideHandler); let discovered: Awaited>; try { + await this.sharingCoordinator().reconcileActive(); discovered = await this.discoverPublishedShare(); } catch (error) { this.startLocalHeartbeat(); @@ -276,71 +295,7 @@ export class BrowserOwnerWorkspaceRuntime { }); return this.getState(); } - this.share = discovered.share; - this.credentials = discovered.credentials; - const factory = this.options.authorityFactory - ?? ((authorityOptions) => new BrowserOwnerAuthorityService(authorityOptions)); - const authority = factory({ - workspaceId: this.options.workspaceId, - holderId: this.options.holderId, - roomId: discovered.share.roomId, - capId: discovered.share.capId, - owner: discovered.credentials, - files: discovered.bindings, - storage: this.authorityStorage(discovered.rootKey), - leaseManager: this.leaseManager, - attachedLease: lease, - sessionOptions: { - ...this.options.sessionOptions, - relayUrl: discovered.share.relayUrl, - // BrowserSession owns and closes its persistence connection. - // Never hand it the app service's shared BrowserStorage handle. - storageFactory: (createIfMissing) => - this.options.storage.openSibling(createIfMissing), - }, - collab: this.options.collab, - rollover: { - onRequired: (input) => this.commitRolloverAndPublish( - input.fileId, - input.doc, - input.publicationOutbox, - ), - }, - ...(this.options.heartbeatIntervalMs === undefined - ? {} - : { heartbeatIntervalMs: this.options.heartbeatIntervalMs }), - ...(this.options.now === undefined ? {} : { now: this.options.now }), - onState: (authorityState) => this.onAuthorityState(authorityState), - }); - this.authority = authority; - // Publish the durable room identity before transport startup. If the - // owner opens offline, the shell can still show recovered review state - // and its honest paused status instead of masquerading as local-only. - this.patchState({ - roomId: discovered.share.roomId, - capId: discovered.share.capId, - bindings: discovered.bindings, - authority: authority.getState(), - }); - const started = await authority.start(); - if (!started) { - this.onAuthorityState(authority.getState()); - return this.getState(); - } - this.refreshController(); - const reconciled = await this.reconcileStartupPublication(discovered); - if (!reconciled) return this.getState(); - this.patchState({ - status: 'active', - leaseRole: 'owner', - writable: true, - liveEditingAvailable: true, - reason: null, - roomId: discovered.share.roomId, - capId: discovered.share.capId, - bindings: discovered.bindings, - authority: authority.getState(), - }); + await this.activatePublishedShare(discovered, lease); return this.getState(); } catch (error) { if (this.lease) { @@ -387,6 +342,64 @@ export class BrowserOwnerWorkspaceRuntime { }); } + async inspectShare(browserReviewBase: string): Promise { + const coordinator = this.sharingCoordinator(); + return coordinator.inspect(browserReviewBase); + } + + async ensureShare(request: BrowserWorkspaceShareRequest): Promise { + return this.enqueueMutation(async () => { + if (!this.stateValue.writable || !this.lease) { + throw new StorageConflictError('browser owner workspace is not writable'); + } + const coordinator = this.sharingCoordinator(); + const view = await coordinator.ensurePublished(request); + if (!this.authority) { + const discovered = await this.discoverPublishedShare(); + if (!discovered) throw new StorageConflictError('published share could not be reopened'); + try { + await this.activatePublishedShare(discovered, this.requireFence()); + } catch (error) { + await this.deactivateAuthority(); + this.startLocalHeartbeat(); + this.patchState({ + status: 'error', + leaseRole: 'owner', + writable: true, + liveEditingAvailable: false, + reason: errorMessage(error), + }); + } + } + return view; + }); + } + + async stopShare(): Promise { + return this.enqueueMutation(async () => { + if (!this.stateValue.writable || !this.lease) { + throw new StorageConflictError('browser owner workspace is not writable'); + } + const coordinator = this.sharingCoordinator(); + // Do not claim a stop until the owner-signed relay deletion succeeds. + const record = await coordinator.deleteRemote(); + await this.deactivateAuthority(); + await coordinator.eraseLocal(record); + this.startLocalHeartbeat(); + this.patchState({ + status: 'active', + leaseRole: 'owner', + writable: true, + liveEditingAvailable: false, + reason: null, + roomId: null, + capId: null, + bindings: [], + authority: null, + }); + }); + } + async accept(input: BrowserOwnerWorkspaceAcceptInput): Promise { return this.enqueueMutation(async () => { const authority = this.requireActiveAuthority(); @@ -571,14 +584,100 @@ export class BrowserOwnerWorkspaceRuntime { return this.closePromise; } - private async discoverPublishedShare(): Promise<{ - share: ShareRecordView; - rootKey: CryptoKey; - credentials: BrowserOwnerCredentials; - bindings: BrowserOwnerAuthorityFile[]; - pendingPublication: boolean; - localHeadsMoved: boolean; - } | null> { + private sharingCoordinator(): BrowserWorkspaceSharingCoordinator { + return new BrowserWorkspaceSharingCoordinator( + this.options.storage, + this.options.workspaceId, + this.requireFence(), + { + ...this.options.sharing, + ...(this.options.publisher === undefined ? {} : { publish: this.options.publisher }), + ...(this.options.now === undefined ? {} : { now: this.options.now }), + }, + ); + } + + private async activatePublishedShare( + discovered: DiscoveredPublishedShare, + lease: LeaseHandle, + ): Promise { + this.stopLocalHeartbeat(); + this.share = discovered.share; + this.credentials = discovered.credentials; + const factory = this.options.authorityFactory + ?? ((authorityOptions) => new BrowserOwnerAuthorityService(authorityOptions)); + const authority = factory({ + workspaceId: this.options.workspaceId, + holderId: this.options.holderId, + roomId: discovered.share.roomId, + capId: discovered.share.capId, + owner: discovered.credentials, + files: discovered.bindings, + storage: this.authorityStorage(discovered.rootKey), + leaseManager: this.leaseManager, + attachedLease: lease, + sessionOptions: { + ...this.options.sessionOptions, + relayUrl: discovered.share.relayUrl, + // BrowserSession owns and closes its persistence connection. + // Never hand it the app service's shared BrowserStorage handle. + storageFactory: (createIfMissing) => + this.options.storage.openSibling(createIfMissing), + }, + collab: this.options.collab, + rollover: { + onRequired: (input) => this.commitRolloverAndPublish( + input.fileId, + input.doc, + input.publicationOutbox, + ), + }, + ...(this.options.heartbeatIntervalMs === undefined + ? {} + : { heartbeatIntervalMs: this.options.heartbeatIntervalMs }), + ...(this.options.now === undefined ? {} : { now: this.options.now }), + onState: (authorityState) => this.onAuthorityState(authorityState), + }); + this.authority = authority; + this.patchState({ + roomId: discovered.share.roomId, + capId: discovered.share.capId, + bindings: discovered.bindings, + authority: authority.getState(), + }); + const started = await authority.start(); + if (!started) { + this.onAuthorityState(authority.getState()); + return false; + } + this.refreshController(); + const reconciled = await this.reconcileStartupPublication(discovered); + if (!reconciled) return false; + this.patchState({ + status: 'active', + leaseRole: 'owner', + writable: true, + liveEditingAvailable: true, + reason: null, + roomId: discovered.share.roomId, + capId: discovered.share.capId, + bindings: discovered.bindings, + authority: authority.getState(), + }); + return true; + } + + private async deactivateAuthority(): Promise { + const authority = this.authority; + this.authority = null; + if (authority) await authority.close().catch(() => undefined); + this.refreshController(); + zeroOwnerCredentials(this.credentials); + this.credentials = null; + this.share = null; + } + + private async discoverPublishedShare(): Promise { const rootKey = await this.options.storage.getWorkspaceRootKey(this.options.workspaceId); if (!rootKey) throw new BrowserStorageError('workspace key is unavailable'); const candidates: Array<{ @@ -601,7 +700,9 @@ export class BrowserOwnerWorkspaceRuntime { ); } const { share, capability } = candidates[0]!; - const credentials = ownerCredentialsFromInviteCapability(capability, share.roomId); + const credentials = capability.durableShare + ? ownerCredentialsV3FromInviteCapability(capability, share.roomId) + : ownerCredentialsFromInviteCapability(capability, share.roomId); try { const manifest = capability.publishedManifest; if (!manifest) throw new StorageConflictError('active share has no promoted manifest'); @@ -668,13 +769,9 @@ export class BrowserOwnerWorkspaceRuntime { }; } - private async reconcileStartupPublication(discovered: { - share: ShareRecordView; - rootKey: CryptoKey; - bindings: readonly BrowserOwnerAuthorityFile[]; - pendingPublication: boolean; - localHeadsMoved: boolean; - }): Promise { + private async reconcileStartupPublication( + discovered: DiscoveredPublishedShare, + ): Promise { if (!discovered.pendingPublication && !discovered.localHeadsMoved) return true; const authority = this.requireActiveAuthority(); const binding = discovered.bindings[0]!; @@ -801,6 +898,7 @@ export class BrowserOwnerWorkspaceRuntime { } const publisher = this.options.publisher ?? publishBrowserSnapshots; await publisher({ + protocolVersion: credentials.protocolVersion, relayUrl: share.relayUrl, roomId: share.roomId, roomSecret: credentials.roomSecret, @@ -1042,6 +1140,11 @@ function zeroOwnerCredentials(credentials: BrowserOwnerCredentials | null): void credentials.keys.snapshotKey.fill(0); credentials.keys.signalingKey.fill(0); credentials.keys.admissionKey.fill(0); + credentials.readAdmissionKey?.fill(0); + credentials.readCapabilityKey?.fill(0); + if ('shareSecret' in credentials && credentials.shareSecret instanceof Uint8Array) { + credentials.shareSecret.fill(0); + } credentials.identity.signingSecret.fill(0); credentials.identity.signingPublic.fill(0); credentials.identity.encryptionSecret.fill(0); diff --git a/web/src/lib/review/browser-session.test.ts b/web/src/lib/review/browser-session.test.ts index a4e05f6a..f2f5c35d 100644 --- a/web/src/lib/review/browser-session.test.ts +++ b/web/src/lib/review/browser-session.test.ts @@ -310,7 +310,7 @@ function ownerCapability(credentials: BrowserOwnerCredentials): InviteCapability interface MockServer { port: number; - onClient: (handler: (ws: WebSocket, subprotocol: string) => void) => void; + onClient: (handler: (ws: WebSocket, subprotocol: string, requestUrl: string) => void) => void; close: () => Promise; } @@ -322,7 +322,7 @@ async function startMockServer(): Promise { throw new Error('mock server returned unexpected address'); } const port = addr.port; - const handlers: Array<(ws: WebSocket, sub: string) => void> = []; + const handlers: Array<(ws: WebSocket, sub: string, requestUrl: string) => void> = []; server.on('connection', (ws, req) => { const protoHeader: string | string[] | undefined = req.headers['sec-websocket-protocol']; const sub = @@ -333,7 +333,7 @@ async function startMockServer(): Promise { : ''; for (const h of handlers) { try { - h(ws, sub); + h(ws, sub, req.url ?? ''); } catch (err) { console.error('mock handler threw:', err); } @@ -643,7 +643,7 @@ async function assertInvalidBlobRefRejected( }); }); const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -1384,7 +1384,7 @@ defineCase('happy path: invite → POST /devices → WS hello → connected', as }); }); - const inviteUrl = composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET); + const inviteUrl = composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET); const states: BrowserSessionState[] = []; const identity = deterministicIdentity(); const session = new BrowserSession({ @@ -1628,7 +1628,7 @@ defineCase('browser authoring posts signed ciphertext, echoes locally, and prese }); }); const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity, powToken: 'test-registration-pow', @@ -1763,7 +1763,7 @@ defineCase('POST /devices 403 → status=error, kind=device_register', async () server.onClient(() => { // never reached — fetch fails first }); - const inviteUrl = composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET); + const inviteUrl = composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET); const session = new BrowserSession({ inviteUrl, relayUrl: `http://127.0.0.1:${server.port}`, @@ -1798,7 +1798,7 @@ defineCase('registration PoW uses authenticated room policy difficulty', async ( server.onClient(() => undefined); const highPowPolicy = { ...POLICY, powBits: 19 }; const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), store, @@ -1854,7 +1854,7 @@ defineCase('SnapshotCreated event populates state.snapshotContent + store', asyn }); }); - const inviteUrl = composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET); + const inviteUrl = composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET); const session = new BrowserSession({ inviteUrl, relayUrl: `http://127.0.0.1:${server.port}`, @@ -1954,7 +1954,7 @@ defineCase('mailbox snapshot_blob rehydrates a native SnapshotCreated pointer', }); const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -2028,7 +2028,7 @@ defineCase('R2 snapshot_blob downloads, authenticates, and rehydrates a native p const relayUrl = `http://127.0.0.1:${server.port}`; const requested: string[] = []; const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -2106,7 +2106,7 @@ defineCase('unknown snapshot signer refreshes GET /devices and retries once', as }); let deviceGets = 0; const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -2170,7 +2170,7 @@ defineCase('HTML SnapshotCreated populates content + docType=html (read-only)', }); }); - const inviteUrl = composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET); + const inviteUrl = composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET); const session = new BrowserSession({ inviteUrl, relayUrl: `http://127.0.0.1:${server.port}`, @@ -2231,7 +2231,7 @@ defineCase('terminal close after hydration clears session and store plaintext', }); }); const session = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -2266,7 +2266,7 @@ defineCase('Admission rejected (WS close 4000) → kind=admission_rejected', asy ws.close(4000, 'admission rejected'); }); }); - const inviteUrl = composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET); + const inviteUrl = composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET); const session = new BrowserSession({ inviteUrl, relayUrl: `http://127.0.0.1:${server.port}`, @@ -2302,15 +2302,18 @@ defineCase('remembered room restores two files, cursor, identity, and sealed off navigator: { storage: { persist: async () => true, estimate: async () => ({}) } }, }); const server = await startMockServer(); - const subscribeAfter: number[] = []; + const subscribeAfterByDevice = new Map(); let subscriptions = 0; try { - server.onClient((ws) => { + server.onClient((ws, _subprotocol, requestUrl) => { + const deviceId = new URL(requestUrl, 'http://127.0.0.1').searchParams.get('device_id') ?? 'unknown'; ws.on('message', (raw) => { const msg = JSON.parse(String(raw)); if (msg.type !== 'subscribe') return; subscriptions += 1; - subscribeAfter.push(msg.after as number); + const deviceSubscriptions = subscribeAfterByDevice.get(deviceId) ?? []; + deviceSubscriptions.push(msg.after as number); + subscribeAfterByDevice.set(deviceId, deviceSubscriptions); ws.send(JSON.stringify({ type: 'hello', serverSeq: 11, @@ -2348,7 +2351,7 @@ defineCase('remembered room restores two files, cursor, identity, and sealed off const firstStore = makeStubStore(); const first = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: deterministicIdentity(), powToken: 'test-pow-token', @@ -2368,7 +2371,7 @@ defineCase('remembered room restores two files, cursor, identity, and sealed off assertEq(first.getState().persistence, 'remembered', 'explicit remember succeeds'); const duplicate = new BrowserSession({ - inviteUrl: composeInviteUrl('http://example.com/review', ROOM_ID, ROOM_SECRET), + inviteUrl: composeInviteUrl('https://example.com/review', ROOM_ID, ROOM_SECRET), relayUrl: `http://127.0.0.1:${server.port}`, identity: generateBrowserIdentity(), powToken: 'test-pow-token', @@ -2467,7 +2470,20 @@ defineCase('remembered room restores two files, cursor, identity, and sealed off assert(thirdStore.events.some( (event) => event.body.type === 'comment_created' && event.body.body === 'SEALED-OFFLINE-COMMENT', ), 'sealed pending comment decrypts after another reload'); - assert(subscribeAfter.slice(1).every((after) => after === 11), 'reload subscribes from committed cursor'); + for ( + let i = 0; + i < 50 && (subscribeAfterByDevice.get('br-test-device')?.length ?? 0) < 3; + i += 1 + ) { + await delay(10); + } + const rememberedSubscriptions = subscribeAfterByDevice.get('br-test-device') ?? []; + assertEq(rememberedSubscriptions[0], 0, 'first invite starts from an empty cursor'); + assert( + rememberedSubscriptions.length >= 3 + && rememberedSubscriptions.slice(1).every((after) => after === 11), + `remembered device reloads subscribe from committed cursor: ${JSON.stringify(rememberedSubscriptions)}`, + ); await third.forgetRoom(); third.close(); const forgotten = await openStorage(false); diff --git a/web/src/lib/review/browser-session.ts b/web/src/lib/review/browser-session.ts index d548d235..15414034 100644 --- a/web/src/lib/review/browser-session.ts +++ b/web/src/lib/review/browser-session.ts @@ -40,10 +40,14 @@ import { buildAdmissionSubprotocolV3, contentHash, deriveRoomId, + deriveRoomIdV3, + deriveRoomKeyTreeV3, deriveRoomKeys, deriveReadKeysV3, + deriveShareEpochRoomSecret, toCanonicalBytes, type RoomKeys, + type RoomKeyTreeV3, } from './browser-crypto'; import { decodeCanonicalBase64Url, @@ -179,13 +183,24 @@ export interface BrowserDeviceIdentity { /** Already-owned browser room material. The session validates and copies it. */ export interface BrowserOwnerCredentials { + protocolVersion?: 2 | 3; roomId: string; roomSecret: Uint8Array; keys: RoomKeys; + /** Present only for v3; `keys.admissionKey` is the write leaf. */ + readAdmissionKey?: Uint8Array; + readCapabilityKey?: Uint8Array; identity: BrowserDeviceIdentity; policy: RoomPolicy; } +export interface BrowserOwnerCredentialsV3 extends BrowserOwnerCredentials { + protocolVersion: 3; + shareSecret: Uint8Array; + shareId: string; + epoch: number; +} + export interface BrowserCollabDelivery { envelopeId: string; source: DecodedEnvelope['source']; @@ -357,6 +372,23 @@ function activeV2Keys(keys: Omit & { rootKey?: Uint8Array } }; } +function activeOwnerKeys(credentials: BrowserOwnerCredentials): ActiveRoomKeys { + if ((credentials.protocolVersion ?? 2) === 2) return activeV2Keys(credentials.keys); + if (!credentials.readAdmissionKey || !credentials.readCapabilityKey) { + throw new Error('v3 owner read capability is unavailable'); + } + return { + version: 3, + rootKey: credentials.keys.rootKey, + readCapabilityKey: credentials.readCapabilityKey, + eventKey: credentials.keys.eventKey, + snapshotKey: credentials.keys.snapshotKey, + signalingKey: credentials.keys.signalingKey, + readAdmissionKey: credentials.readAdmissionKey, + writeAdmissionKey: credentials.keys.admissionKey, + }; +} + // --------------------------------------------------------------------------- // Identity generation // --------------------------------------------------------------------------- @@ -453,6 +485,74 @@ export function ownerCredentialsFromInviteCapability( } } +/** Reconstruct one durable share's exact v3 epoch owner from sealed state. */ +export function ownerCredentialsV3FromInviteCapability( + capability: InviteCapability, + expectedRoomId: string, +): BrowserOwnerCredentialsV3 { + const durable = capability.durableShare; + if (!durable || durable.protocolVersion !== 3) { + throw new Error('sealed owner capability is not a durable v3 share'); + } + let roomSecret: Uint8Array | null = null; + let shareSecret: Uint8Array | null = null; + let signingSecret: Uint8Array | null = null; + let encryptionSecret: Uint8Array | null = null; + try { + roomSecret = decodeCanonicalSecret(capability.roomSecret, 'room secret'); + shareSecret = decodeCanonicalSecret(durable.shareSecret, 'share secret'); + const derivedEpochSecret = deriveShareEpochRoomSecret(shareSecret, durable.epoch); + try { + if (base64UrlEncode(derivedEpochSecret) !== base64UrlEncode(roomSecret)) { + throw new Error('sealed v3 epoch secret does not match the share root'); + } + } finally { + derivedEpochSecret.fill(0); + } + const roomId = deriveRoomIdV3(roomSecret); + if (roomId !== expectedRoomId || durable.currentRoomId !== undefined && durable.currentRoomId !== roomId) { + throw new Error('sealed v3 owner roomId does not match its binding'); + } + signingSecret = decodeCanonicalSecret(capability.ownerSigningSecret, 'owner signing secret'); + encryptionSecret = decodeCanonicalSecret(capability.ownerEncryptionSecret, 'owner encryption secret'); + const signingPublic = ed25519.getPublicKey(signingSecret); + const publicEncryptionKey = x25519.getPublicKey(encryptionSecret); + const tree = deriveRoomKeyTreeV3(roomSecret); + return { + protocolVersion: 3, + roomId, + roomSecret, + shareSecret, + keys: { + rootKey: tree.rootKey, + eventKey: tree.readKeys.eventKey, + snapshotKey: tree.readKeys.snapshotKey, + signalingKey: tree.readKeys.signalingKey, + admissionKey: tree.writeAdmissionKey, + }, + readAdmissionKey: tree.readKeys.readAdmissionKey, + readCapabilityKey: tree.readKeys.readCapabilityKey, + identity: { + deviceId: capability.ownerDeviceId, + participantId: capability.ownerParticipantId, + signingSecret, + signingPublic, + encryptionSecret, + publicEncryptionKey, + }, + policy: validateRoomPolicy(capability.policy), + shareId: durable.shareId, + epoch: durable.epoch, + }; + } catch (error) { + roomSecret?.fill(0); + shareSecret?.fill(0); + signingSecret?.fill(0); + encryptionSecret?.fill(0); + throw error; + } +} + function decodeCanonicalSecret(value: unknown, label: string): Uint8Array { if (typeof value !== 'string') throw new Error(`sealed ${label} is invalid`); let bytes: Uint8Array; @@ -518,18 +618,36 @@ function cloneAndValidateOwnerCredentials( if (!(input.roomSecret instanceof Uint8Array) || input.roomSecret.length !== 32) { throw new Error('owner roomSecret must be 32 bytes'); } - if (deriveRoomId(input.roomSecret) !== input.roomId) { + const protocolVersion = input.protocolVersion ?? 2; + if ((protocolVersion === 3 ? deriveRoomIdV3(input.roomSecret) : deriveRoomId(input.roomSecret)) !== input.roomId) { throw new Error('owner roomSecret does not derive the bound roomId'); } - const derived = deriveRoomKeys(input.roomSecret); + const derivedTree = protocolVersion === 3 ? deriveRoomKeyTreeV3(input.roomSecret) : null; + const derived = derivedTree === null ? deriveRoomKeys(input.roomSecret) : { + rootKey: derivedTree.rootKey, + eventKey: derivedTree.readKeys.eventKey, + snapshotKey: derivedTree.readKeys.snapshotKey, + signalingKey: derivedTree.readKeys.signalingKey, + admissionKey: derivedTree.writeAdmissionKey, + }; try { for (const key of ['rootKey', 'eventKey', 'snapshotKey', 'signalingKey', 'admissionKey'] as const) { if (!(input.keys[key] instanceof Uint8Array) || !equalBytes(input.keys[key], derived[key])) { throw new Error(`owner ${key} does not match roomSecret`); } } + if (protocolVersion === 3 && ( + !(input.readAdmissionKey instanceof Uint8Array) + || !(input.readCapabilityKey instanceof Uint8Array) + || !equalBytes(input.readAdmissionKey, derivedTree!.readKeys.readAdmissionKey) + || !equalBytes(input.readCapabilityKey, derivedTree!.readKeys.readCapabilityKey) + )) { + throw new Error('owner v3 read capability does not match roomSecret'); + } } finally { zeroRoomKeys(derived); + derivedTree?.readKeys.readAdmissionKey.fill(0); + derivedTree?.readKeys.readCapabilityKey.fill(0); } const identity = input.identity; if (!identity.deviceId || !identity.participantId) throw new Error('owner identity ids are invalid'); @@ -562,6 +680,7 @@ function cloneAndValidateOwnerCredentials( // copies, so rejected credentials cannot strand cloned secret material. const policy = validateRoomPolicy(input.policy); return { + protocolVersion, roomId: input.roomId, roomSecret: new Uint8Array(input.roomSecret), keys: { @@ -571,6 +690,12 @@ function cloneAndValidateOwnerCredentials( signalingKey: new Uint8Array(input.keys.signalingKey), admissionKey: new Uint8Array(input.keys.admissionKey), }, + ...(protocolVersion === 3 + ? { + readAdmissionKey: new Uint8Array(input.readAdmissionKey!), + readCapabilityKey: new Uint8Array(input.readCapabilityKey!), + } + : {}), identity: { deviceId: identity.deviceId, participantId: identity.participantId, @@ -1355,7 +1480,7 @@ export class BrowserSession { this.fail('device_register', error instanceof Error ? error.message : String(error)); return; } - const activeKeys = activeV2Keys(credentials.keys); + const activeKeys = activeOwnerKeys(credentials); this.ownerRoomSecret = credentials.roomSecret; this.keys = activeKeys; this.identity = credentials.identity; diff --git a/web/src/lib/review/browser-share-owner.test.ts b/web/src/lib/review/browser-share-owner.test.ts new file mode 100644 index 00000000..c51d1c31 --- /dev/null +++ b/web/src/lib/review/browser-share-owner.test.ts @@ -0,0 +1,144 @@ +import { + BrowserShareOwnerRelayClient, + EMPTY_SHARE_MANIFEST_DIGEST, + buildShareBundleMutations, + composeShareTierInvites, + digestShareSnapshotManifest, + sealDurableShareSnapshot, +} from './browser-share-owner'; +import { + base64UrlEncode, + buildOwnerSignatureHeader, + deriveRoomIdV3, + deriveRoomKeyTreeV3, + deriveShareLinkKeys, + toCanonicalBytes, +} from './browser-crypto'; +import { openShareCapabilityBundle, parseShareInvite } from './browser-share'; +import { decryptDurableShareSnapshot } from './browser-share-production'; +import { generateBrowserIdentity } from './browser-session'; + +function assert(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} + +async function run(): Promise { + const shareId = base64UrlEncode(new Uint8Array(16).fill(7)); + const shareSecret = new Uint8Array(32).fill(9); + const roomSecret = new Uint8Array(32).fill(11); + const room = deriveRoomKeyTreeV3(roomSecret); + const roomId = deriveRoomIdV3(roomSecret); + const identity = generateBrowserIdentity(); + const ownerSigningKey = base64UrlEncode(identity.signingPublic); + + assert(digestShareSnapshotManifest([]) === EMPTY_SHARE_MANIFEST_DIGEST, 'empty digest parity'); + + let nonceByte = 0; + const mutations = buildShareBundleMutations({ + shareId, + shareSecret, + epoch: 0, + revision: 4, + manifestDigest: EMPTY_SHARE_MANIFEST_DIGEST, + roomId, + ownerSigningKey, + readCapabilityKey: room.readKeys.readCapabilityKey, + writeAdmissionKey: room.writeAdmissionKey, + commentGrantSignature: base64UrlEncode(new Uint8Array(64).fill(3)), + suggestGrantSignature: base64UrlEncode(new Uint8Array(64).fill(5)), + randomBytes: (length) => new Uint8Array(length).fill(++nonceByte), + }); + assert(mutations.length === 3, 'three mutations'); + for (const mutation of mutations) { + const keys = deriveShareLinkKeys(shareSecret, mutation.tier); + const opened = openShareCapabilityBundle(keys.bundleKey, keys.bundleId, { + shareId, + epoch: 0, + revision: 4, + manifestDigest: EMPTY_SHARE_MANIFEST_DIGEST, + tier: mutation.tier, + }, mutation.sealedBundle); + assert(opened.roomId === roomId && opened.tier === mutation.tier, 'bundle context'); + } + + const invites = composeShareTierInvites(shareId, shareSecret); + const parsed = [invites.view, invites.comment, invites.suggest] + .map((invite) => parseShareInvite(invite.browserUrl)); + assert(parsed.every((invite) => invite.shareId === shareId), 'stable share id'); + assert(new Set(parsed.map((invite) => base64UrlEncode(invite.linkSecret))).size === 3, 'sibling secrets'); + + const snapshotId = base64UrlEncode(new Uint8Array(16).fill(13)); + const fileId = base64UrlEncode(new Uint8Array(16).fill(15)); + const sealed = sealDurableShareSnapshot({ + shareId, + epoch: 0, + fileId, + snapshotId, + docType: 'markdown', + content: '# Browser owner\n', + snapshotKey: room.readKeys.snapshotKey, + nonce: new Uint8Array(24).fill(17), + }); + const decoded = decryptDurableShareSnapshot(shareId, 0, { + v: 3, + shareId, + bundleId: mutations[0]!.bundleId, + epoch: 0, + revision: 4, + manifestDigest: EMPTY_SHARE_MANIFEST_DIGEST, + roomId, + tier: 'view', + roomCapability: { ownerSigningKey, readCapabilityKey: room.readKeys.readCapabilityKey, roomKeys: room.readKeys }, + }, fileId, snapshotId, sealed); + assert(decoded.content === '# Browser owner\n', 'durable ciphertext interop'); + + const requests: Array<{ url: string; init: RequestInit }> = []; + const record = { + v: 3, shareId, ownerSigningKey, epoch: 0, revision: 0, + snapshots: [], placeholders: [], manifestDigest: EMPTY_SHARE_MANIFEST_DIGEST, + updatedAt: 1, expiresAt: 2, mailbox: { count: 0, bytes: 0, latestSeq: 0 }, + }; + const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + return Response.json(record, { status: init?.method === 'POST' ? 201 : 200 }); + }) as typeof fetch; + const client = new BrowserShareOwnerRelayClient({ + relayUrl: 'https://relay.example', + shareId, + identity, + fetchImpl, + mintPow: async () => 'pow', + }); + const body = { + v: 3 as const, + ownerSigningKey, + bundles: mutations, + epoch: 0, + revision: 0, + currentRoomId: null, + snapshots: [], + placeholders: [], + deviceId: identity.deviceId, + }; + await client.upsert(body); + await client.fetchWithViewCapability(shareSecret); + const post = requests[0]!; + const bodyText = post.init.body as string; + const path = `/v3/shares/${shareId}`; + assert( + (post.init.headers as Record)['Attn-Owner-Signature'] + === buildOwnerSignatureHeader(identity.signingSecret, 'POST', path, new TextEncoder().encode(bodyText)), + 'owner request signature', + ); + assert((requests[1]!.init.headers as Record)['Attn-Admission'].startsWith('v3.read.'), 'view read proof'); + + // Ensure the plaintext shape stays canonical and free of accidental fields. + const canonical = toCanonicalBytes({ v: 3, fileId, snapshotId, docType: 'markdown', content: '# Browser owner\n' }); + assert(canonical.length > 0, 'canonical snapshot bytes'); + console.log('browser-share-owner: 6 passed, 0 failed'); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/web/src/lib/review/browser-share-owner.ts b/web/src/lib/review/browser-share-owner.ts new file mode 100644 index 00000000..0b9a2c86 --- /dev/null +++ b/web/src/lib/review/browser-share-owner.ts @@ -0,0 +1,533 @@ +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { + base64UrlEncode, + buildAdmissionHeaderV3, + buildOwnerSignatureHeader, + deriveShareLinkKeys, + randomAeadNonce, + toCanonicalBytes, + type ShareLinkTier, +} from './browser-crypto'; +import { mintBrowserPowInWorker, type BrowserPowInputs } from './browser-pow'; +import { composeShareInvite, sealShareCapabilityBundle, type ShareCapabilityBundle } from './browser-share'; +import { validateBrowserRelayUrl } from './browser-relay-url'; +import type { BrowserDeviceIdentity } from './browser-session'; + +export const EMPTY_SHARE_MANIFEST_DIGEST = 'T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU'; +export const DURABLE_SHARE_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; + +export interface ShareBundleMutation { + bundleId: string; + tier: ShareLinkTier; + readAdmissionKey: string; + writeAdmissionKey?: string; + sealedBundle: string; +} + +export interface ManagedShareSnapshotRef { + fileId: string; + snapshotId: string; + ciphertextBytes: number; + ciphertextSha256: string; + uploadedAt: number; +} + +export interface BrowserShareRelayRecord { + v: 3; + shareId: string; + ownerSigningKey: string; + epoch: number; + revision: number; + currentRoomId?: string; + snapshots: ManagedShareSnapshotRef[]; + placeholders: unknown[]; + manifestDigest: string; + updatedAt: number; + expiresAt: number; + mailbox: { count: number; bytes: number; latestSeq: number }; +} + +export interface BrowserShareMailboxItem { + seq: number; + envelopeId: string; + bytes: number; + payload: unknown; + epoch: number; + bundleId: string; + tier: 'comment' | 'suggest'; +} + +export interface BrowserShareMailboxPage { + items: BrowserShareMailboxItem[]; + nextAfter: number; + bundle: { bundleId: string; tier: 'comment' | 'suggest'; sealedBundle: string }; +} + +export interface BrowserShareUpsertRequest { + v: 3; + ownerSigningKey: string; + bundles: ShareBundleMutation[]; + epoch: number; + revision: number; + currentRoomId: string | null; + snapshots: ManagedShareSnapshotRef[]; + placeholders: unknown[]; + deviceId: string; +} + +export interface ShareTierInviteForms { + tier: ShareLinkTier; + browserUrl: string; + nativeUrl: string; + cliCommand: string; +} + +export interface ShareTierInvites { + view: ShareTierInviteForms; + comment: ShareTierInviteForms; + suggest: ShareTierInviteForms; +} + +export interface ShareEpochBundleContext { + shareId: string; + shareSecret: Uint8Array; + epoch: number; + revision: number; + manifestDigest: string; + roomId: string; + ownerSigningKey: string; + readCapabilityKey: Uint8Array; + writeAdmissionKey: Uint8Array; + commentGrantSignature: string; + suggestGrantSignature: string; + randomBytes?: (length: number) => Uint8Array; +} + +/** Seal all three stable sibling bearers to one exact epoch projection. */ +export function buildShareBundleMutations(input: ShareEpochBundleContext): ShareBundleMutation[] { + const random = input.randomBytes ?? ((length) => crypto.getRandomValues(new Uint8Array(length))); + return (['view', 'comment', 'suggest'] as const).map((tier) => { + const keys = deriveShareLinkKeys(input.shareSecret, tier); + try { + const bundle: ShareCapabilityBundle = { + v: 3, + purpose: 'attn share capability bundle v3', + bundleId: keys.bundleId, + ownerSigningKey: input.ownerSigningKey, + shareId: input.shareId, + epoch: input.epoch, + revision: input.revision, + manifestDigest: input.manifestDigest, + tier, + roomId: input.roomId, + readCapabilityKey: base64UrlEncode(input.readCapabilityKey), + ...(tier === 'view' + ? {} + : { + writeAdmissionKey: base64UrlEncode(input.writeAdmissionKey), + grantSignature: tier === 'comment' + ? input.commentGrantSignature + : input.suggestGrantSignature, + }), + }; + const nonce = random(24); + if (!(nonce instanceof Uint8Array) || nonce.length !== 24) { + throw new Error('share bundle nonce source returned the wrong length'); + } + try { + return { + bundleId: keys.bundleId, + tier, + readAdmissionKey: base64UrlEncode(keys.readAdmissionKey), + ...(keys.writeAdmissionKey === undefined + ? {} + : { writeAdmissionKey: base64UrlEncode(keys.writeAdmissionKey) }), + sealedBundle: sealShareCapabilityBundle(keys.bundleKey, keys.bundleId, bundle, nonce), + }; + } finally { + nonce.fill(0); + } + } finally { + keys.linkSecret.fill(0); + keys.bundleKey.fill(0); + keys.readAdmissionKey.fill(0); + keys.writeAdmissionKey?.fill(0); + } + }); +} + +/** Materialize public strings only while the Share sheet is open. */ +export function composeShareTierInvites(shareId: string, shareSecret: Uint8Array): ShareTierInvites { + const result = {} as ShareTierInvites; + for (const tier of ['view', 'comment', 'suggest'] as const) { + const keys = deriveShareLinkKeys(shareSecret, tier); + try { + const nativeUrl = composeShareInvite(shareId, keys.linkSecret); + const browserUrl = composeShareInvite(shareId, keys.linkSecret, 'https://attn.sh/'); + result[tier] = { + tier, + browserUrl, + nativeUrl, + cliCommand: `npx attnmd review join '${nativeUrl}'`, + }; + } finally { + keys.linkSecret.fill(0); + keys.bundleKey.fill(0); + keys.readAdmissionKey.fill(0); + keys.writeAdmissionKey?.fill(0); + } + } + return result; +} + +export interface SealDurableSnapshotInput { + shareId: string; + epoch: number; + fileId: string; + snapshotId: string; + docType: 'markdown' | 'html'; + content: string; + metadata?: unknown; + snapshotKey: Uint8Array; + nonce?: Uint8Array; +} + +/** Native-compatible nonce || XChaCha20-Poly1305 retained snapshot. */ +export function sealDurableShareSnapshot(input: SealDurableSnapshotInput): Uint8Array { + const nonce = input.nonce ? new Uint8Array(input.nonce) : randomAeadNonce(); + if (input.snapshotKey.length !== 32 || nonce.length !== 24) { + nonce.fill(0); + throw new Error('durable snapshot key or nonce length is invalid'); + } + const aad = toCanonicalBytes({ + v: 3, + purpose: 'attn durable share snapshot v3', + shareId: input.shareId, + epoch: input.epoch, + fileId: input.fileId, + snapshotId: input.snapshotId, + }); + const plaintext = toCanonicalBytes({ + v: 3, + fileId: input.fileId, + snapshotId: input.snapshotId, + docType: input.docType, + content: input.content, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }); + let ciphertext: Uint8Array | null = null; + try { + ciphertext = xchacha20poly1305(input.snapshotKey, nonce, aad).encrypt(plaintext); + const sealed = new Uint8Array(nonce.length + ciphertext.length); + sealed.set(nonce, 0); + sealed.set(ciphertext, nonce.length); + return sealed; + } finally { + nonce.fill(0); + aad.fill(0); + plaintext.fill(0); + ciphertext?.fill(0); + } +} + +export function digestShareSnapshotManifest(refs: readonly ManagedShareSnapshotRef[]): string { + const canonical = toCanonicalBytes([...refs] + .sort((left, right) => left.fileId.localeCompare(right.fileId)) + .map((ref) => ({ + ciphertextBytes: ref.ciphertextBytes, + ciphertextSha256: ref.ciphertextSha256, + fileId: ref.fileId, + snapshotId: ref.snapshotId, + uploadedAt: ref.uploadedAt, + }))); + try { + return base64UrlEncode(sha256(canonical)); + } finally { + canonical.fill(0); + } +} + +type OwnerPowRequest = Omit; + +export interface BrowserShareOwnerRelayOptions { + relayUrl: string; + shareId: string; + identity: BrowserDeviceIdentity; + fetchImpl?: typeof fetch; + mintPow?: (input: OwnerPowRequest, signal: AbortSignal) => Promise; + signal?: AbortSignal; +} + +export class BrowserShareOwnerRelayError extends Error { + constructor(readonly status: number, operation: string) { + super(`${operation} failed (${status})`); + this.name = 'BrowserShareOwnerRelayError'; + } +} + +/** Owner-only ShareDO transport. It never accepts or logs a public link secret. */ +export class BrowserShareOwnerRelayClient { + private readonly relay: string; + private readonly fetchImpl: typeof fetch; + private readonly mintPow: (input: OwnerPowRequest, signal: AbortSignal) => Promise; + private readonly signal: AbortSignal; + + constructor(private readonly options: BrowserShareOwnerRelayOptions) { + this.relay = validateBrowserRelayUrl(options.relayUrl); + this.fetchImpl = options.fetchImpl ?? fetch; + this.mintPow = options.mintPow + ?? ((input, signal) => mintBrowserPowInWorker(input, { signal })); + this.signal = options.signal ?? new AbortController().signal; + } + + async upsert(request: BrowserShareUpsertRequest): Promise { + const body = JSON.stringify(request); + const bodyBytes = new TextEncoder().encode(body); + try { + return await this.ownerJson('POST', this.sharePath(), bodyBytes, body); + } finally { + bodyBytes.fill(0); + } + } + + async fetchWithViewCapability(shareSecret: Uint8Array): Promise { + const keys = deriveShareLinkKeys(shareSecret, 'view'); + const path = this.sharePath(); + try { + const response = await this.fetchImpl(`${this.relay}${path}`, { + method: 'GET', + headers: { + 'Attn-Share-Bundle': keys.bundleId, + 'Attn-Admission': buildAdmissionHeaderV3( + keys.readAdmissionKey, + 'read', + 'GET', + path, + new Uint8Array(0), + ), + }, + signal: this.signal, + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + return await decodeRecord(response, this.options.shareId); + } finally { + keys.linkSecret.fill(0); + keys.bundleKey.fill(0); + keys.readAdmissionKey.fill(0); + keys.writeAdmissionKey?.fill(0); + } + } + + async uploadSnapshot( + fileId: string, + snapshotId: string, + ciphertext: Uint8Array, + ): Promise { + const path = `${this.sharePath()}/snapshots/${encodeURIComponent(fileId)}/${encodeURIComponent(snapshotId)}`; + const response = await this.ownerRequest('PUT', path, ciphertext, ciphertext); + if (!response.ok) throw new BrowserShareOwnerRelayError(response.status, 'durable snapshot upload'); + const value = await response.json() as Partial; + return validateSnapshotRef(value); + } + + async deleteSnapshot(fileId: string): Promise { + const path = `${this.sharePath()}/snapshots/${encodeURIComponent(fileId)}`; + const response = await this.ownerRequest('DELETE', path, new Uint8Array(0)); + if (!response.ok && response.status !== 404) { + throw new BrowserShareOwnerRelayError(response.status, 'durable snapshot delete'); + } + } + + async fetchMailbox( + shareSecret: Uint8Array, + tier: 'comment' | 'suggest', + after: number, + ): Promise { + if (!Number.isSafeInteger(after) || after < 0) throw new Error('mailbox cursor is invalid'); + const keys = deriveShareLinkKeys(shareSecret, tier); + const path = `${this.sharePath()}/mailbox`; + const query: Array<[string, string]> = [['after', String(after)], ['limit', '100']]; + try { + const response = await this.fetchImpl(`${this.relay}${path}?${new URLSearchParams(query)}`, { + method: 'GET', + headers: { + 'Attn-Share-Bundle': keys.bundleId, + 'Attn-Admission': buildAdmissionHeaderV3( + keys.readAdmissionKey, + 'read', + 'GET', + path, + new Uint8Array(0), + query, + ), + }, + signal: this.signal, + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + if (!response.ok) throw new BrowserShareOwnerRelayError(response.status, 'durable mailbox fetch'); + return validateMailboxPage(await response.json(), keys.bundleId, tier, after); + } finally { + keys.linkSecret.fill(0); + keys.bundleKey.fill(0); + keys.readAdmissionKey.fill(0); + keys.writeAdmissionKey?.fill(0); + } + } + + async ackMailbox(through: number): Promise { + if (!Number.isSafeInteger(through) || through < 0) throw new Error('mailbox ACK cursor is invalid'); + const query: Array<[string, string]> = [['through', String(through)]]; + const response = await this.ownerRequest( + 'DELETE', + `${this.sharePath()}/mailbox`, + new Uint8Array(0), + undefined, + query, + ); + if (!response.ok) throw new BrowserShareOwnerRelayError(response.status, 'durable mailbox ACK'); + } + + async revoke(): Promise { + const response = await this.ownerRequest('DELETE', this.sharePath(), new Uint8Array(0)); + if (!response.ok && response.status !== 404 && response.status !== 410) { + throw new BrowserShareOwnerRelayError(response.status, 'durable share revoke'); + } + } + + private async ownerJson( + method: 'POST', + path: string, + bytes: Uint8Array, + body: string, + ): Promise { + const response = await this.ownerRequest(method, path, bytes, body); + return decodeRecord(response, this.options.shareId); + } + + private async ownerRequest( + method: 'POST' | 'PUT' | 'DELETE', + path: string, + bytes: Uint8Array, + body?: BodyInit | Uint8Array, + query: Array<[string, string]> = [], + ): Promise { + const pow = await this.mintPow({ + roomId: this.options.shareId, + deviceId: this.options.identity.deviceId, + method, + path, + difficulty: 12, + }, this.signal); + const suffix = query.length === 0 ? '' : `?${new URLSearchParams(query)}`; + return this.fetchImpl(`${this.relay}${path}${suffix}`, { + method, + headers: { + ...(body === undefined ? {} : { + 'content-type': method === 'PUT' + ? 'application/octet-stream' + : 'application/json; charset=utf-8', + }), + 'Attn-Owner-Signature': buildOwnerSignatureHeader( + this.options.identity.signingSecret, + method, + path, + bytes, + query, + ), + 'Attn-PoW': pow, + 'Attn-Device-Id': this.options.identity.deviceId, + }, + ...(body === undefined + ? {} + : { body: body instanceof Uint8Array ? body.slice().buffer as ArrayBuffer : body }), + signal: this.signal, + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + } + + private sharePath(): string { + return `/v3/shares/${encodeURIComponent(this.options.shareId)}`; + } +} + +async function decodeRecord(response: Response, expectedShareId: string): Promise { + if (!response.ok) throw new BrowserShareOwnerRelayError(response.status, 'durable share request'); + const value = await response.json() as Partial; + if ( + value.v !== 3 || value.shareId !== expectedShareId || typeof value.ownerSigningKey !== 'string' + || !Number.isSafeInteger(value.epoch) || !Number.isSafeInteger(value.revision) + || !Array.isArray(value.snapshots) || !Array.isArray(value.placeholders) + || typeof value.manifestDigest !== 'string' || !Number.isSafeInteger(value.updatedAt) + || !Number.isSafeInteger(value.expiresAt) + || !isMailboxSummary(value.mailbox) + || (value.currentRoomId !== undefined && typeof value.currentRoomId !== 'string') + ) { + throw new Error('durable share response is invalid'); + } + const snapshots = value.snapshots.map(validateSnapshotRef); + if (digestShareSnapshotManifest(snapshots) !== value.manifestDigest) { + throw new Error('durable share manifest digest is invalid'); + } + return { ...(value as BrowserShareRelayRecord), snapshots }; +} + +function isMailboxSummary(value: unknown): value is BrowserShareRelayRecord['mailbox'] { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const summary = value as Partial; + return Number.isSafeInteger(summary.count) && (summary.count ?? -1) >= 0 + && Number.isSafeInteger(summary.bytes) && (summary.bytes ?? -1) >= 0 + && Number.isSafeInteger(summary.latestSeq) && (summary.latestSeq ?? -1) >= 0; +} + +function validateMailboxPage( + value: unknown, + bundleId: string, + tier: 'comment' | 'suggest', + after: number, +): BrowserShareMailboxPage { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('durable mailbox page is invalid'); + } + const page = value as Partial; + if (!Array.isArray(page.items) || !Number.isSafeInteger(page.nextAfter) + || (page.nextAfter ?? -1) < after || typeof page.bundle !== 'object' || page.bundle === null + || page.bundle.bundleId !== bundleId || page.bundle.tier !== tier + || typeof page.bundle.sealedBundle !== 'string') { + throw new Error('durable mailbox page is invalid'); + } + const items = page.items.map((raw) => { + const item = raw as Partial; + if (!Number.isSafeInteger(item.seq) || (item.seq ?? 0) <= after + || typeof item.envelopeId !== 'string' || !Number.isSafeInteger(item.bytes) || (item.bytes ?? 0) < 1 + || item.epoch === undefined || !Number.isSafeInteger(item.epoch) + || item.bundleId !== bundleId || item.tier !== tier || item.payload === undefined) { + throw new Error('durable mailbox item is invalid'); + } + return item as BrowserShareMailboxItem; + }); + if ((items.at(-1)?.seq ?? after) !== page.nextAfter) { + throw new Error('durable mailbox cursor does not match its page'); + } + return { items, nextAfter: page.nextAfter, bundle: page.bundle as BrowserShareMailboxPage['bundle'] }; +} + +function validateSnapshotRef(value: Partial): ManagedShareSnapshotRef { + if ( + typeof value.fileId !== 'string' || typeof value.snapshotId !== 'string' + || !Number.isSafeInteger(value.ciphertextBytes) || (value.ciphertextBytes ?? 0) < 1 + || typeof value.ciphertextSha256 !== 'string' || !Number.isSafeInteger(value.uploadedAt) + ) { + throw new Error('durable share snapshot response is invalid'); + } + return value as ManagedShareSnapshotRef; +} diff --git a/web/src/lib/review/browser-share-production.ts b/web/src/lib/review/browser-share-production.ts index 7c7e3b83..b7be62cd 100644 --- a/web/src/lib/review/browser-share-production.ts +++ b/web/src/lib/review/browser-share-production.ts @@ -4,6 +4,7 @@ import { BrowserSession, buildRegisterDeviceBodyV3, generateBrowserIdentity, + type BrowserSessionOptions, type ReviewStoreSink, } from './browser-session'; import { assembleBrowserEvent } from './browser-envelope'; @@ -117,6 +118,8 @@ export interface ProductionDurableShareSessionOptions extends BrowserDurableShar disableWebRtc?: boolean; /** PoW execution seam for non-Window production-boundary harnesses. */ mailboxMintPow?: (input: { shareId: string; deviceId: string; path: string; signal?: AbortSignal }) => Promise; + registrationMintPow?: BrowserSessionOptions['registrationMintPow']; + outboxMintPow?: BrowserSessionOptions['outboxMintPow']; /** Browser seams used by the focused consent harness. */ pushConsentDependencies?: Omit; onPushConsentState?: (state: BrowserPushConsentState) => void; @@ -182,6 +185,8 @@ export async function createProductionDurableShareSession(options: ProductionDur signingSecret: new Uint8Array(identity.signingSecret), signingPublic: new Uint8Array(identity.signingPublic), encryptionSecret: new Uint8Array(identity.encryptionSecret), publicEncryptionKey: new Uint8Array(identity.publicEncryptionKey) }, onState: options.onLiveState, store: options.liveStore, disableWebRtc: options.disableWebRtc, + ...(options.registrationMintPow === undefined ? {} : { registrationMintPow: options.registrationMintPow }), + ...(options.outboxMintPow === undefined ? {} : { outboxMintPow: options.outboxMintPow }), parsedInvite: { version: 3, roomId: resolution.bundle.roomId, tier: resolution.bundle.tier, readCapabilityKey: new Uint8Array(capability.readCapabilityKey), diff --git a/web/src/lib/review/browser-snapshot-publisher.test.ts b/web/src/lib/review/browser-snapshot-publisher.test.ts index 19debe8c..b10402ae 100644 --- a/web/src/lib/review/browser-snapshot-publisher.test.ts +++ b/web/src/lib/review/browser-snapshot-publisher.test.ts @@ -205,6 +205,58 @@ test('mailbox publishes blob before signed pointer and marks only after ACK', as equal(((manifest.manifest as Record).entries as unknown[]).length, 1, 'manifest lists entry'); }); +test('owner genesis prefix is journaled and flushed before initial snapshots', async () => { + const outbox = new FakeOutbox(); + const sink = new FakePublicationSink(); + const roomId = 'room-prefix'; + const prefix: MailboxEnvelope[] = [1, 2].map((fill) => ({ + v: 2, + roomId, + envelopeId: base64UrlEncode(new Uint8Array(16).fill(fill)), + authorId: identity.participantId, + deviceId: identity.deviceId, + createdAt: now - 3 + fill, + expiresAt: policy.expiresAt, + kind: 'event', + nonce: base64UrlEncode(new Uint8Array(24).fill(fill)), + ciphertext: base64UrlEncode(new Uint8Array(32).fill(fill)), + ciphertextBytes: 32, + })); + const source = { + path: 'genesis.md', + bytes: new TextEncoder().encode('# Initial publication'), + docType: 'markdown' as const, + revisionId: revisionId(62), + }; + await publishBrowserSnapshots({ + relayUrl: 'https://relay.example', + roomId, + roomSecret, + keys, + identity, + policy, + entries: [source], + prefixEnvelopes: prefix, + indexBuilder, + outbox, + now: () => now, + randomBytes, + publication: { + workspaceId: 'ws-prefix', + capId: 'cap-prefix', + sink, + fence, + revisionSource: matchingRevisionSource([source]), + }, + }); + equal( + outbox.envelopes.slice(0, 2).map((envelope) => envelope.envelopeId), + prefix.map((envelope) => envelope.envelopeId), + 'RoomCreated and owner ParticipantJoined retain prefix order', + ); + equal(sink.commits, 1, 'prefix is inside the acknowledged publication boundary'); +}); + test('fenced publication requires an exact source revision', async () => { const sink = new FakePublicationSink(); sink.published = { diff --git a/web/src/lib/review/browser-snapshot-publisher.ts b/web/src/lib/review/browser-snapshot-publisher.ts index 7579d8c5..b6ac6ea9 100644 --- a/web/src/lib/review/browser-snapshot-publisher.ts +++ b/web/src/lib/review/browser-snapshot-publisher.ts @@ -108,6 +108,7 @@ export interface BrowserSnapshotPublicationResult { } export interface PublishBrowserSnapshotsOptions { + protocolVersion?: 2 | 3; relayUrl: string; roomId: string; roomSecret: Uint8Array; @@ -115,6 +116,8 @@ export interface PublishBrowserSnapshotsOptions { identity: BrowserDeviceIdentity; policy: RoomPolicy; entries: readonly BrowserSnapshotEntry[]; + /** One-time room genesis events, atomically journaled before initial snapshots. */ + prefixEnvelopes?: readonly MailboxEnvelope[]; scope?: WorkspaceManifestScope; outbox: SnapshotPublicationOutbox; publication?: { @@ -196,7 +199,9 @@ export async function publishBrowserSnapshots( const results: BrowserSnapshotPublicationResult[] = []; const manifestEntries: WorkspaceManifestEntry[] = []; - const envelopes: MailboxEnvelope[] = []; + const envelopes: MailboxEnvelope[] = (options.prefixEnvelopes ?? []).map((envelope) => + structuredClone(envelope) + ); for (const { source, baseHash, fileId } of resolved) { const snapshotId = deriveSnapshotId(options.roomId, fileId, baseHash, createdAt); @@ -371,6 +376,7 @@ async function prepareSnapshot( throw new Error('encrypted snapshot exceeds the room snapshot limit'); } await (options.uploadR2 ?? uploadBrowserR2Snapshot)({ + protocolVersion: options.protocolVersion, relayUrl: options.relayUrl, roomId: options.roomId, admissionKey: options.keys.admissionKey, @@ -443,6 +449,16 @@ function validateOptions(options: PublishBrowserSnapshotsOptions): void { throw new Error(`published workspace entry requires revisionId: ${entry.path}`); } } + for (const envelope of options.prefixEnvelopes ?? []) { + if ( + envelope.roomId !== options.roomId + || envelope.deviceId !== options.identity.deviceId + || envelope.authorId !== options.identity.participantId + || envelope.kind !== 'event' + ) { + throw new Error('snapshot publication prefix envelope is not owner-room bound'); + } + } } function sameBytes(left: Uint8Array, right: Uint8Array): boolean { diff --git a/web/src/lib/review/browser-snapshot-r2.ts b/web/src/lib/review/browser-snapshot-r2.ts index d748ab69..73b5b786 100644 --- a/web/src/lib/review/browser-snapshot-r2.ts +++ b/web/src/lib/review/browser-snapshot-r2.ts @@ -36,6 +36,7 @@ export type SnapshotUploadPowRequest = Omit< >; export interface UploadBrowserR2SnapshotOptions { + protocolVersion?: 2 | 3; relayUrl: string; roomId: string; admissionKey: Uint8Array; @@ -88,7 +89,8 @@ export async function uploadBrowserR2Snapshot( ): Promise { validateUploadInputs(options); const relay = parseRelayOrigin(options.relayUrl); - const path = `/v2/rooms/${encodeURIComponent(options.roomId)}/blobs`; + const version = options.protocolVersion ?? 2; + const path = `/v${version}/rooms/${encodeURIComponent(options.roomId)}/blobs`; const bodyJson = JSON.stringify({ envelopeId: options.envelopeId, authorId: options.authorId, @@ -118,7 +120,9 @@ export async function uploadBrowserR2Snapshot( method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8', - 'Attn-Admission': buildAdmissionHeader(options.admissionKey, 'POST', path, bodyBytes), + 'Attn-Admission': version === 3 + ? buildAdmissionHeaderV3(options.admissionKey, 'write', 'POST', path, bodyBytes) + : buildAdmissionHeader(options.admissionKey, 'POST', path, bodyBytes), 'Attn-PoW': pow, }, body: bodyJson, diff --git a/web/src/lib/review/browser-workspace-share.test.ts b/web/src/lib/review/browser-workspace-share.test.ts index 200b2745..d5ce1595 100644 --- a/web/src/lib/review/browser-workspace-share.test.ts +++ b/web/src/lib/review/browser-workspace-share.test.ts @@ -87,7 +87,7 @@ async function acquireFence( return lease; } -function sampleCapability() { +function sampleCapability(sharePaths?: string[]) { const identity = generateBrowserIdentity(); return inviteCapabilityFrom({ roomSecret: new Uint8Array(32).fill(7), @@ -96,6 +96,7 @@ function sampleCapability() { ownerDeviceId: identity.deviceId, ownerParticipantId: identity.participantId, policy: { mode: 'hybrid', maxPeers: 8 }, + ...(sharePaths === undefined ? {} : { sharePaths }), }); } @@ -258,6 +259,53 @@ defineCase('publication state advances and shares list/forget', async () => { } }); +defineCase('fenced preparation allows one active share and fenced crypto-erasure', async () => { + const { storage } = await openStorage(); + try { + const workspaceId = 'ws-fenced-prepare'; + const rootKey = await storage.createWorkspaceKey(workspaceId); + const fence = await acquireFence(storage, workspaceId, 'active-tab'); + const prepared = await storage.shares.bindShareFenced(rootKey, { + workspaceId, + capId: 'cap-prepared', + roomId: 'room-prepared', + scopeKind: 'entries', + relayUrl: 'https://relay.example', + capability: sampleCapability(['notes.md', 'assets/diagram.png']), + }, fence); + assertEqual(prepared.publication, 'pending', 'prepared before network'); + const opened = await storage.shares.openShare(rootKey, workspaceId, prepared.capId); + assertEqual(opened.sharePaths?.join('|'), 'notes.md|assets/diagram.png', 'scope paths sealed'); + + await expectStorageError( + storage.shares.bindShareFenced(rootKey, { + workspaceId, + capId: 'cap-second', + roomId: 'room-second', + scopeKind: 'workspace', + relayUrl: 'https://relay.example', + capability: sampleCapability(['notes.md']), + }, fence), + 'one active room per workspace', + ); + await expectStorageError( + storage.shares.forgetShareFenced( + workspaceId, + prepared.capId, + { ...fence, holderId: 'stale-tab' }, + ), + 'passive tab cannot erase ownership', + ); + assert( + await storage.shares.forgetShareFenced(workspaceId, prepared.capId, fence), + 'active owner erased capability', + ); + assertEqual((await storage.shares.listShares(workspaceId)).length, 0, 'late ACK has no record to revive'); + } finally { + storage.close(); + } +}); + defineCase('publication commit atomically reseals manifest pointer and stable FileIds', async () => { const { storage, reopen } = await openStorage(); const rootKey = await storage.createWorkspaceKey('ws-1'); diff --git a/web/src/lib/review/browser-workspace-share.ts b/web/src/lib/review/browser-workspace-share.ts index 560b3dde..27ff374c 100644 --- a/web/src/lib/review/browser-workspace-share.ts +++ b/web/src/lib/review/browser-workspace-share.ts @@ -57,8 +57,12 @@ export interface InviteCapability { ownerEncryptionSecret: string; ownerDeviceId: string; ownerParticipantId: string; + /** Stable ShareDO owner root and crash-recovery projection for v3 shares. */ + durableShare?: DurableShareCapabilityState; /** Snapshot of the room policy at share time. */ policy: unknown; + /** Exact normalized scope paths retained before the first network request. */ + sharePaths?: string[]; /** Published-revision pointer (attn-7xl.4.3 updates it). */ publishedRevisionId?: string; /** Last fully-acknowledged manifest and stable per-entry identities. */ @@ -71,6 +75,19 @@ export interface InviteCapability { }; } +export interface DurableShareCapabilityState { + protocolVersion: 3; + shareId: string; + shareSecret: string; + epoch: number; + revision: number; + manifestDigest: string; + currentRoomId?: string; + expiresAt?: number; + drainCursor?: number; + lifecycle: 'active' | 'revoke_pending'; +} + export interface ShareRecordView { workspaceId: string; capId: string; @@ -198,6 +215,86 @@ export class WorkspaceShareStore { return toView(record); } + /** + * Prepare one active share under the route's live fence before contacting + * the relay. This closes the create→bind crash window and prevents two tabs + * from minting different active rooms for one workspace. + */ + async bindShareFenced( + rootKey: CryptoKey, + input: BindShareInput, + fence: WorkspaceFence, + ): Promise { + validateWorkspaceRootKey(rootKey); + requireId(input.workspaceId, 'workspaceId'); + requireId(input.capId, 'capId'); + requireId(input.roomId, 'roomId'); + requireRelay(input.relayUrl); + + const plaintext = new TextEncoder().encode( + JSON.stringify({ ...input.capability, v: CAPABILITY_VERSION }), + ); + const meta = { + workspaceId: input.workspaceId, + capId: input.capId, + roomId: input.roomId, + scopeKind: input.scopeKind, + } as const; + let sealed: { nonce: string; ciphertext: string }; + try { + sealed = await sealCapability(this.cryptoImpl, rootKey, meta, plaintext); + } finally { + plaintext.fill(0); + } + const record: StoredShareRecord = { + v: WORKSPACE_RECORD_VERSION, + workspaceId: input.workspaceId, + capId: input.capId, + roomId: input.roomId, + scopeKind: input.scopeKind, + createdAt: this.timestamp(), + nonce: sealed.nonce, + ciphertext: sealed.ciphertext, + relayUrl: input.relayUrl, + publication: 'pending', + generation: 0, + }; + validateWorkspaceShareCapRecord(record); + + const tx = this.db.transaction( + [STORE_WORKSPACE_SHARE_CAPS, STORE_WORKSPACE_LEASES], + 'readwrite', + ); + const done = transactionDone(tx); + await this.assertActiveFenceInTransaction(tx, input.workspaceId, fence); + const store = tx.objectStore(STORE_WORKSPACE_SHARE_CAPS); + const records = await requestValue( + store.index(WORKSPACE_INDEX).getAll(IDBKeyRange.only(input.workspaceId)), + ); + const existing = records.find((candidate) => candidate.capId === input.capId); + if (existing) { + if ( + existing.roomId !== input.roomId + || existing.scopeKind !== input.scopeKind + || existing.relayUrl !== input.relayUrl + ) { + tx.abort(); + await done.catch(() => undefined); + throw new StorageConflictError('share capId already belongs to different ownership'); + } + await done; + return toView(existing); + } + if (records.some((candidate) => candidate.publication !== 'stopped')) { + tx.abort(); + await done.catch(() => undefined); + throw new StorageConflictError('workspace already has an active share'); + } + store.add(record); + await done; + return toView(record); + } + /** Open the sealed invite capability for a resumed share. Caller zeroes it. */ async openShare(rootKey: CryptoKey, workspaceId: string, capId: string): Promise { validateWorkspaceRootKey(rootKey); @@ -677,6 +774,63 @@ export class WorkspaceShareStore { return toView(next); } + /** + * Fenced reseal of the owner-only ShareDO projection. Network transitions + * journal here before links become visible or revocation starts. + */ + async updateDurableShareFenced( + rootKey: CryptoKey, + workspaceId: string, + capId: string, + durableShare: DurableShareCapabilityState, + fence: WorkspaceFence, + policy?: unknown, + ): Promise { + validateWorkspaceRootKey(rootKey); + const state = validateDurableShareCapability(durableShare); + const original = await this.getRaw(workspaceId, capId); + if (!original) throw new BrowserStorageError(`share does not exist: ${capId}`); + if (original.publication === 'stopped') { + throw new StorageConflictError('stopped share cannot update durable ownership'); + } + const capability = await this.openRecord(rootKey, original); + if (capability.durableShare && capability.durableShare.shareId !== state.shareId) { + throw new StorageConflictError('durable share id is immutable'); + } + if (policy !== undefined && (typeof policy !== 'object' || policy === null)) { + throw new BrowserStorageError('durable share policy is invalid'); + } + const sealed = await this.sealRecordCapability(rootKey, original, { + ...capability, + durableShare: state, + ...(policy === undefined ? {} : { policy }), + }); + const tx = this.db.transaction( + [STORE_WORKSPACE_SHARE_CAPS, STORE_WORKSPACE_LEASES], + 'readwrite', + ); + const done = transactionDone(tx); + await this.assertActiveFenceInTransaction(tx, workspaceId, fence); + const store = tx.objectStore(STORE_WORKSPACE_SHARE_CAPS); + const current = await requestValue( + store.get([workspaceId, capId]), + ); + if (!sameGeneration(current, original)) { + tx.abort(); + await done.catch(() => undefined); + throw new StorageConflictError('share capability changed while updating durable ownership'); + } + const next: StoredShareRecord = { + ...current, + nonce: sealed.nonce, + ciphertext: sealed.ciphertext, + generation: generationOf(current) + 1, + }; + store.put(next); + await done; + return toView(next); + } + private async assertActiveFence( workspaceId: string, fence: WorkspaceFence, @@ -740,6 +894,27 @@ export class WorkspaceShareStore { return existing !== undefined; } + /** Crypto-erase a stopped capability only while this route still owns it. */ + async forgetShareFenced( + workspaceId: string, + capId: string, + fence: WorkspaceFence, + ): Promise { + const tx = this.db.transaction( + [STORE_WORKSPACE_SHARE_CAPS, STORE_WORKSPACE_LEASES], + 'readwrite', + ); + const done = transactionDone(tx); + await this.assertActiveFenceInTransaction(tx, workspaceId, fence); + const store = tx.objectStore(STORE_WORKSPACE_SHARE_CAPS); + const existing = await requestValue( + store.get([workspaceId, capId]), + ); + if (existing) store.delete([workspaceId, capId]); + await done; + return existing !== undefined; + } + private async getRaw(workspaceId: string, capId: string): Promise { const tx = this.db.transaction(STORE_WORKSPACE_SHARE_CAPS, 'readonly'); const done = transactionDone(tx); @@ -766,7 +941,9 @@ export function inviteCapabilityFrom(input: { ownerEncryptionSecret: Uint8Array; ownerDeviceId: string; ownerParticipantId: string; + durableShare?: DurableShareCapabilityState; policy: unknown; + sharePaths?: string[]; publishedRevisionId?: string; publishedManifest?: PublishedManifestPointer; }): InviteCapability { @@ -777,7 +954,11 @@ export function inviteCapabilityFrom(input: { ownerEncryptionSecret: base64UrlEncode(input.ownerEncryptionSecret), ownerDeviceId: input.ownerDeviceId, ownerParticipantId: input.ownerParticipantId, + ...(input.durableShare === undefined + ? {} + : { durableShare: validateDurableShareCapability(input.durableShare) }), policy: input.policy, + ...(input.sharePaths === undefined ? {} : { sharePaths: validateSharePaths(input.sharePaths) }), ...(input.publishedRevisionId === undefined ? {} : { publishedRevisionId: input.publishedRevisionId }), @@ -823,8 +1004,15 @@ function validateInviteCapability(value: unknown): InviteCapability { if (parsed.publishedRevisionId !== undefined && typeof parsed.publishedRevisionId !== 'string') { throw new BrowserStorageError('sealed published revision is invalid'); } + const sharePaths = parsed.sharePaths === undefined + ? undefined + : validateSharePaths(parsed.sharePaths); return { ...(parsed as InviteCapability), + ...(sharePaths === undefined ? {} : { sharePaths }), + ...(parsed.durableShare === undefined + ? {} + : { durableShare: validateDurableShareCapability(parsed.durableShare) }), ...(parsed.publishedManifest === undefined ? {} : { publishedManifest: validatePublishedManifest(parsed.publishedManifest) }), @@ -834,6 +1022,52 @@ function validateInviteCapability(value: unknown): InviteCapability { }; } +function validateDurableShareCapability(value: unknown): DurableShareCapabilityState { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new BrowserStorageError('sealed durable share is invalid'); + } + const state = value as Partial; + if ( + state.protocolVersion !== 3 + || typeof state.shareId !== 'string' + || typeof state.shareSecret !== 'string' + || !Number.isSafeInteger(state.epoch) || (state.epoch ?? -1) < 0 + || !Number.isSafeInteger(state.revision) || (state.revision ?? -1) < 0 + || typeof state.manifestDigest !== 'string' + || (state.currentRoomId !== undefined && typeof state.currentRoomId !== 'string') + || (state.expiresAt !== undefined && (!Number.isSafeInteger(state.expiresAt) || state.expiresAt < 0)) + || (state.drainCursor !== undefined && (!Number.isSafeInteger(state.drainCursor) || state.drainCursor < 0)) + || (state.lifecycle !== 'active' && state.lifecycle !== 'revoke_pending') + ) { + throw new BrowserStorageError('sealed durable share is invalid'); + } + try { + if (base64UrlDecode(state.shareId).length !== 16 || base64UrlDecode(state.shareSecret).length !== 32) { + throw new BrowserStorageError('sealed durable share identifiers are invalid'); + } + } catch (error) { + if (error instanceof BrowserStorageError) throw error; + throw new BrowserStorageError('sealed durable share identifiers are invalid'); + } + return { ...(state as DurableShareCapabilityState) }; +} + +function validateSharePaths(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new BrowserStorageError('sealed share paths are invalid'); + } + const paths = value.map((path) => { + if (typeof path !== 'string') throw new BrowserStorageError('sealed share path is invalid'); + const normalized = normalizeEntryPath(path); + if (normalized !== path) throw new BrowserStorageError('sealed share path is not normalized'); + return normalized; + }); + if (new Set(paths).size !== paths.length) { + throw new BrowserStorageError('sealed share paths contain duplicates'); + } + return paths; +} + function validatePendingPublication( value: InviteCapability['pendingPublication'] | unknown, ): NonNullable { diff --git a/web/src/lib/review/browser-workspace-sharing.test.ts b/web/src/lib/review/browser-workspace-sharing.test.ts new file mode 100644 index 00000000..1d63bd6d --- /dev/null +++ b/web/src/lib/review/browser-workspace-sharing.test.ts @@ -0,0 +1,467 @@ +import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { + aeadOpen, + base64UrlDecode, + base64UrlEncode, + deriveRoomIdV3, + deriveRoomKeyTreeV3, + deriveShareLinkKeys, +} from './browser-crypto'; +import type { CreateOwnedRoomOptions, OwnedRoomBootstrapV3 } from './browser-owner-bootstrap'; +import { assembleBrowserEvent } from './browser-envelope'; +import { + publishBrowserSnapshots, + type PublishBrowserSnapshotsOptions, +} from './browser-snapshot-publisher'; +import { + BrowserShareOwnerRelayError, + digestShareSnapshotManifest, + type BrowserShareRelayRecord, + type BrowserShareUpsertRequest, + type ManagedShareSnapshotRef, +} from './browser-share-owner'; +import { BrowserStorage } from './browser-storage'; +import { BrowserStorageError, StorageConflictError } from './browser-storage-errors'; +import { + buildRegisterDeviceBodyV3, + canonicalDeviceGrantV3, + generateBrowserIdentity, + ownerCredentialsV3FromInviteCapability, + type RegisterDeviceBodyV3, +} from './browser-session'; +import type { LeaseHandle } from './browser-workspace-lease'; +import { + BrowserWorkspaceSharingCoordinator, + type BrowserShareOwnerRelayPort, + type BrowserWorkspaceShareOutbox, + type BrowserWorkspaceShareRequest, +} from './browser-workspace-sharing'; +import type { MailboxEnvelope } from './browser-ws'; +import type { ReviewEvent } from '../types'; + +Object.defineProperty(globalThis, 'IDBKeyRange', { configurable: true, value: IDBKeyRange }); + +interface Result { name: string; ok: boolean; detail?: string } +const cases: Array<() => Promise> = []; +function test(name: string, run: () => Promise): void { + cases.push(async () => { + try { await run(); return { name, ok: true }; } + catch (error) { return { name, ok: false, detail: error instanceof Error ? error.stack ?? error.message : String(error) }; } + }); +} +function assert(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } +function required(value: unknown, message: string): T { assert(value, message); return value as T; } +function equal(actual: unknown, expected: unknown, message: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +let databaseCounter = 0; +const NOW = 1_720_000_000_000; +async function openStorage(): Promise { + return BrowserStorage.open({ + indexedDB: new IDBFactory(), databaseName: `browser-workspace-sharing-v3-${++databaseCounter}`, + createIfMissing: true, filesystem: null, navigator: null, now: () => NOW, + }); +} +async function seedWorkspace(storage: BrowserStorage, workspaceId: string): Promise { + await storage.workspaces.createWorkspace({ workspaceId, name: workspaceId, storagePersisted: true, + entry: { path: 'notes/main.md', kind: 'markdown', body: new TextEncoder().encode('# Main\n') } }); + await storage.workspaces.createEntry({ workspaceId, path: 'assets/image.png', kind: 'asset', + mediaType: 'image/png', body: new Uint8Array([137, 80, 78, 71, 0, 255]) }); + await storage.workspaces.createEntry({ workspaceId, path: 'appendix.md', kind: 'markdown', + body: new TextEncoder().encode('Appendix\n') }); +} +async function acquireFence(storage: BrowserStorage, workspaceId: string): Promise { + const leases = storage.leases({ channel: null }); + const fence = await leases.acquire(workspaceId, `${workspaceId}-owner`); leases.close(); + assert(fence, 'workspace fence acquired'); return fence; +} +function deterministicRandom(): (length: number) => Uint8Array { + let counter = 1; return (length) => new Uint8Array(length).fill(counter++); +} +function request(scopeKind: BrowserWorkspaceShareRequest['scopeKind'], paths: readonly string[]): BrowserWorkspaceShareRequest { + return { relayUrl: 'https://relay.example', browserReviewBase: 'https://attn.sh', scopeKind, paths, + ownerDisplayName: ' Workspace owner ' }; +} + +function bootstrapFromOptions(options: CreateOwnedRoomOptions): OwnedRoomBootstrapV3 { + assert(options.roomSecret && options.identity && options.policy, 'prepared v3 bootstrap inputs'); + const roomSecret = new Uint8Array(options.roomSecret); + return { + roomId: deriveRoomIdV3(roomSecret), roomSecret, keys: deriveRoomKeyTreeV3(roomSecret), + identity: options.identity, policy: options.policy, + commentGrantSignature: base64UrlEncode(new Uint8Array(64).fill(3)), + suggestGrantSignature: base64UrlEncode(new Uint8Array(64).fill(5)), created: true, + }; +} + +class AckingOutbox implements BrowserWorkspaceShareOutbox { + readonly envelopes: MailboxEnvelope[] = []; + constructor(private readonly storage: BrowserStorage, private readonly roomId: string, private failFlush = false) {} + async initialize(): Promise {} + async enqueueBatchDurably(envelopes: readonly MailboxEnvelope[]): Promise { + for (const envelope of envelopes) { + if (!this.envelopes.some(candidate => candidate.envelopeId === envelope.envelopeId)) { + this.envelopes.push(structuredClone(envelope)); + } + } + return envelopes.length; + } + async flushNow(): Promise { + if (this.failFlush) { this.failFlush = false; throw new Error('relay offline'); } + await this.storage.acknowledge(this.roomId, this.envelopes, + this.envelopes.map((envelope, index) => ({ envelopeId: envelope.envelopeId, serverSeq: index + 1 }))); + } + close(): void {} +} + +class MemoryShareRelay implements BrowserShareOwnerRelayPort { + record: BrowserShareRelayRecord | null = null; + readonly upserts: BrowserShareUpsertRequest[] = []; + revoked = false; + readonly mailItems: Array<{ + seq: number; envelopeId: string; bytes: number; payload: unknown; + epoch: number; bundleId: string; tier: 'comment' | 'suggest'; + }> = []; + ackedThrough = 0; + async upsert(request: BrowserShareUpsertRequest): Promise { + this.upserts.push(structuredClone(request)); + this.record = { + v: 3, shareId: this.shareId, ownerSigningKey: request.ownerSigningKey, + epoch: request.epoch, revision: request.revision, + ...(request.currentRoomId === null ? {} : { currentRoomId: request.currentRoomId }), + snapshots: structuredClone(request.snapshots), placeholders: structuredClone(request.placeholders), + manifestDigest: digestShareSnapshotManifest(request.snapshots), updatedAt: NOW, + expiresAt: NOW + 90 * 24 * 60 * 60 * 1000, + mailbox: { count: 0, bytes: 0, latestSeq: 0 }, + }; + return structuredClone(this.record); + } + constructor(private readonly shareId: string) {} + async fetchWithViewCapability(): Promise { + if (!this.record || this.revoked) throw new BrowserShareOwnerRelayError(404, 'fetch'); + return structuredClone(this.record); + } + async uploadSnapshot(fileId: string, snapshotId: string, ciphertext: Uint8Array): Promise { + assert(this.record, 'dark share exists before retained upload'); + const ref = { fileId, snapshotId, ciphertextBytes: ciphertext.length, + ciphertextSha256: base64UrlEncode(sha256(ciphertext)), uploadedAt: NOW + this.record.revision + 1 }; + const snapshots = this.record.snapshots.filter(item => item.fileId !== fileId); snapshots.push(ref); + snapshots.sort((a, b) => a.fileId.localeCompare(b.fileId)); + this.record = { ...this.record, snapshots, revision: this.record.revision + 1, + manifestDigest: digestShareSnapshotManifest(snapshots) }; + return structuredClone(ref); + } + async deleteSnapshot(fileId: string): Promise { + assert(this.record, 'share exists'); + const snapshots = this.record.snapshots.filter(item => item.fileId !== fileId); + if (snapshots.length !== this.record.snapshots.length) { + this.record = { ...this.record, snapshots, revision: this.record.revision + 1, + manifestDigest: digestShareSnapshotManifest(snapshots) }; + } + } + async fetchMailbox(shareSecret: Uint8Array, tier: 'comment' | 'suggest', after: number) { + const keys = deriveShareLinkKeys(shareSecret, tier); + try { + const items = this.mailItems.filter(item => item.tier === tier && item.seq > after); + return { items: structuredClone(items), nextAfter: items.at(-1)?.seq ?? after, + bundle: { bundleId: keys.bundleId, tier, sealedBundle: 'selected' } }; + } finally { + keys.linkSecret.fill(0); keys.bundleKey.fill(0); keys.readAdmissionKey.fill(0); keys.writeAdmissionKey?.fill(0); + } + } + async ackMailbox(through: number): Promise { + this.ackedThrough = through; + const remaining = this.mailItems.filter(item => item.seq > through); + this.mailItems.splice(0, this.mailItems.length, ...remaining); + if (this.record) this.record = { ...this.record, mailbox: { + count: remaining.length, bytes: remaining.reduce((sum, item) => sum + item.bytes, 0), + latestSeq: this.record.mailbox.latestSeq, + } }; + } + async revoke(): Promise { this.revoked = true; } +} + +function indexBuilder(markdown: Uint8Array) { + return Promise.resolve({ docHash: base64UrlEncode(sha256(markdown)), canonicalEncoding: 'utf8-bytes' as const, + lineCount: new TextDecoder().decode(markdown).split('\n').length, blocks: [], headings: [] }); +} +function decryptEvent(envelope: MailboxEnvelope, eventKey: Uint8Array): ReviewEvent { + const plaintext = aeadOpen(eventKey, base64UrlDecode(envelope.nonce), base64UrlDecode(envelope.ciphertext), { + v: 2, roomId: envelope.roomId!, envelopeId: envelope.envelopeId, kind: envelope.kind, + authorId: envelope.authorId, deviceId: envelope.deviceId, createdAt: envelope.createdAt, + }); + try { return JSON.parse(new TextDecoder().decode(plaintext)) as ReviewEvent; } + finally { plaintext.fill(0); } +} + +test('publishes one dark ShareDO projection, retained snapshot, then stable tier links', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-genesis'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let relay: MemoryShareRelay | null = null; + let createOptions: CreateOwnedRoomOptions | null = null; let outbox: AckingOutbox | null = null; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, randomBytes: deterministicRandom(), + createRoom: async options => { + createOptions = { ...options, roomSecret: new Uint8Array(options.roomSecret!) }; + return bootstrapFromOptions(options); + }, + publish: options => publishBrowserSnapshots({ ...options, indexBuilder }), + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => (outbox = new AckingOutbox(db, credentials.roomId)), + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + }); + const view = await coordinator.ensurePublished(request('entries', ['notes/main.md', 'assets/image.png'])); + assert(view.invite, 'v3 share completed'); + const observedCreate = required(createOptions, 'create observed'); + const observedOutbox = required(outbox, 'outbox observed'); + const observedRelay = required(relay, 'share relay observed'); + assert(view.shareId === view.capId, 'stable share id is sealed binding id'); + assert(view.roomId === deriveRoomIdV3(observedCreate.roomSecret!), 'ordinary epoch room is v3-derived'); + assert(observedRelay.upserts[0]!.currentRoomId === null, 'first projection is dark'); + assert(observedRelay.upserts.at(-1)!.currentRoomId === view.roomId, 'final projection flips room pointer'); + assert(observedRelay.record!.snapshots.length === 1, 'markdown retained while binary remains live-room inert'); + const urls = [view.invite.view.browserUrl, view.invite.comment.browserUrl, view.invite.suggest.browserUrl]; + assert(urls.every(url => url.startsWith(`https://attn.sh/s/${view.shareId}#key=`)), 'stable /s links'); + assert(new Set(urls).size === 3, 'tier bearers are independent'); + const tree = deriveRoomKeyTreeV3(observedCreate.roomSecret!); + const roomCreated = decryptEvent(observedOutbox.envelopes[0]!, tree.readKeys.eventKey); + assert(roomCreated.body.type === 'room_created', 'owner genesis leads publication'); + } finally { storage.close(); } +}); + +test('pending ordinary ciphertext resumes exactly before ShareDO promotion', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-resume'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); const outboxes: AckingOutbox[] = []; + let creates = 0; let publishes = 0; let relay: MemoryShareRelay | null = null; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, randomBytes: deterministicRandom(), + createRoom: async options => { creates += 1; return bootstrapFromOptions(options); }, + publish: options => { publishes += 1; return publishBrowserSnapshots({ ...options, indexBuilder }); }, + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => { + const value = new AckingOutbox(db, credentials.roomId, outboxes.length === 0); outboxes.push(value); return value; + }, + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + }); + let failed = false; + try { await coordinator.ensurePublished(request('file', ['notes/main.md'])); } + catch (error) { failed = error instanceof Error && error.message === 'relay offline'; } + assert(failed, 'first relay flush fails'); + const pending = await coordinator.inspect('https://attn.sh'); assert(pending?.resumable, 'pending is resumable'); + const exact = JSON.stringify(outboxes[0]!.envelopes); + const active = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(active.invite, 'stable links appear after resume'); + assert(publishes === 1, 'resume does not re-encrypt'); + assert(creates === 2, 'room rejoin is idempotently retried'); + assert(JSON.stringify(outboxes[1]!.envelopes) === exact, 'exact ciphertext adopted'); + } finally { storage.close(); } +}); + +test('active stable share renews ShareDO without republishing a live epoch room', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-renew'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let relay: MemoryShareRelay | null = null; + let creates = 0; let publishes = 0; let clock = NOW; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => clock, randomBytes: deterministicRandom(), + createRoom: async options => { + creates += 1; + return { ...bootstrapFromOptions(options), created: creates === 1 }; + }, + publish: options => { publishes += 1; return publishBrowserSnapshots({ ...options, indexBuilder }); }, + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => new AckingOutbox(db, credentials.roomId), + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + }); + const first = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + const firstUpserts = required(relay, 'share relay').upserts.length; + clock += 60_000; + const renewed = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(renewed.shareId === first.shareId && renewed.roomId === first.roomId, 'renewal preserves stable and epoch ids'); + assert(creates === 2, 'renewal reasserts the ordinary room'); + assert(publishes === 1, 'live room renewal does not republish unchanged snapshots'); + assert(required(relay, 'share relay').upserts.length === firstUpserts + 1, + 'renewal touches ShareDO even when its projection is unchanged'); + const rootKey = await storage.getWorkspaceRootKey(workspaceId); assert(rootKey, 'root key'); + const capability = await storage.shares.openShare(rootKey, workspaceId, first.capId); + assert((capability.policy as { expiresAt: number }).expiresAt === clock + 24 * 60 * 60 * 1000, + 'renewal seals the refreshed ordinary-room policy'); + } finally { storage.close(); } +}); + +test('missing ordinary epoch room republishes under the same stable links', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-room-recreate'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let relay: MemoryShareRelay | null = null; + let publishes = 0; let clock = NOW; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => clock, randomBytes: deterministicRandom(), + createRoom: async options => bootstrapFromOptions(options), + publish: options => { publishes += 1; return publishBrowserSnapshots({ ...options, indexBuilder }); }, + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => new AckingOutbox(db, credentials.roomId), + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + }); + const first = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + const firstRevision = required(relay, 'share relay').record!.revision; + clock += 60_000; + const recovered = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(recovered.shareId === first.shareId && recovered.roomId === first.roomId, + 'same epoch recovery leaves every public URL stable'); + assert(publishes === 2, 'a recreated ordinary room receives a fresh canonical publication'); + assert(required(relay, 'share relay').record!.revision > firstRevision, + 'retained projection advances and rebinds its sealed bundles after recovery'); + } finally { storage.close(); } +}); + +test('browser owner validates, forwards, and ACKs an offline stable-link comment', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-mailbox-drain'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let relay: MemoryShareRelay | null = null; + let creates = 0; let publishes = 0; const registrations: RegisterDeviceBodyV3[] = []; + const outboxes: AckingOutbox[] = []; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, randomBytes: deterministicRandom(), + createRoom: async options => { + creates += 1; + return { ...bootstrapFromOptions(options), created: creates === 1 }; + }, + publish: options => { publishes += 1; return publishBrowserSnapshots({ ...options, indexBuilder }); }, + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => { + const outbox = new AckingOutbox(db, credentials.roomId); outboxes.push(outbox); return outbox; + }, + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + registerFrozenDevice: async input => { registrations.push(structuredClone(input.registration)); }, + }); + const first = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + const rootKey = await storage.getWorkspaceRootKey(workspaceId); assert(rootKey, 'root key'); + const capability = await storage.shares.openShare(rootKey, workspaceId, first.capId); + const credentials = ownerCredentialsV3FromInviteCapability(capability, first.roomId); + const reviewer = generateBrowserIdentity(); + try { + const grant = base64UrlEncode(ed25519.sign( + canonicalDeviceGrantV3(first.roomId, 'comment'), + credentials.identity.signingSecret, + )); + const registration = buildRegisterDeviceBodyV3(reviewer, 'comment', grant); + const common = { + eventKey: credentials.keys.eventKey, + signingSecret: reviewer.signingSecret, + signingPublic: reviewer.signingPublic, + roomId: first.roomId, + authorId: reviewer.participantId, + deviceId: reviewer.deviceId, + expiresAt: (capability.policy as { expiresAt: number }).expiresAt, + } as const; + const joined = assembleBrowserEvent({ ...common, createdAt: NOW + 10, body: { + type: 'participant_joined', + participant: { participantId: reviewer.participantId, displayName: 'Offline reviewer', kind: 'reviewer', + publicSigningKey: base64UrlEncode(reviewer.signingPublic), + capabilities: ['read_snapshot', 'write_comment', 'resolve_comment'] }, + device: { deviceId: reviewer.deviceId, participantId: reviewer.participantId, + publicEncryptionKey: base64UrlEncode(reviewer.publicEncryptionKey), + publicSigningKey: base64UrlEncode(reviewer.signingPublic), client: 'attn-browser', createdAt: NOW + 10 }, + } }); + const comment = assembleBrowserEvent({ ...common, createdAt: NOW + 11, body: { + type: 'comment_created', threadId: 'offline-thread', anchor: { + v: 2, fileId: 'offline-file', snapshotId: 'offline-snapshot', baseHash: 'offline-hash', + position: { byteRange: [0, 1], lineRange: [1, 1] }, + }, body: 'offline browser comment', + } }); + const link = deriveShareLinkKeys(credentials.shareSecret, 'comment'); + try { + const envelopeId = base64UrlEncode(new Uint8Array(16).fill(91)); + const payload = { v: 3, envelopeId, type: 'review_submission', shareId: first.shareId, + epoch: credentials.epoch, roomId: first.roomId, tier: 'comment', + deviceRegistration: registration, envelopes: [joined.envelope, comment.envelope] }; + const memoryRelay = required(relay, 'share relay'); + memoryRelay.mailItems.push({ seq: 1, envelopeId, bytes: JSON.stringify(payload).length, + payload, epoch: credentials.epoch, bundleId: link.bundleId, tier: 'comment' }); + memoryRelay.record = { ...memoryRelay.record!, mailbox: { + count: 1, bytes: JSON.stringify(payload).length, latestSeq: 1, + } }; + } finally { + link.linkSecret.fill(0); link.bundleKey.fill(0); link.readAdmissionKey.fill(0); link.writeAdmissionKey?.fill(0); + } + await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(registrations.length === 1 && registrations[0]!.deviceId === reviewer.deviceId, + 'owner forwards the exact preflighted visitor registration'); + assert(outboxes[1]!.envelopes.some(item => item.envelopeId === comment.envelope.envelopeId), + 'owner forwards the exact encrypted comment envelope to RoomDO'); + assert(required(relay, 'share relay').ackedThrough === 1, + 'ShareDO prefix is ACKed only after ordinary-room forwarding'); + const after = await storage.shares.openShare(rootKey, workspaceId, first.capId); + assert(after.durableShare?.drainCursor === 1, 'mailbox cursor is sealed for crash-safe replay'); + assert(publishes === 1, 'mailbox drain does not republish a live room'); + } finally { + credentials.shareSecret.fill(0); credentials.roomSecret.fill(0); + credentials.identity.signingSecret.fill(0); reviewer.signingSecret.fill(0); + } + } finally { storage.close(); } +}); + +test('stop journals revoke, kills stable share and epoch room, then crypto-erases locally', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-stop'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let relay: MemoryShareRelay | null = null; let roomDeletes = 0; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, randomBytes: deterministicRandom(), createRoom: async options => bootstrapFromOptions(options), + deleteRoom: async () => { roomDeletes += 1; return true; }, + publish: options => publishBrowserSnapshots({ ...options, indexBuilder }), + indexBuilder, + outboxFactory: ({ storage: db, credentials }) => new AckingOutbox(db, credentials.roomId), + shareRelayFactory: options => (relay ??= new MemoryShareRelay(options.shareId)), + }); + const first = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + const stopped = await coordinator.deleteRemote(); + assert(required(relay, 'share relay').revoked && roomDeletes === 1, 'stable pointer and cached epoch both revoked'); + const rootKey = await storage.getWorkspaceRootKey(workspaceId); assert(rootKey, 'root key'); + const sealed = await storage.shares.openShare(rootKey, workspaceId, stopped.capId); + assert(sealed.durableShare?.lifecycle === 'revoke_pending', 'revoke intent is durable before erasure'); + await coordinator.eraseLocal(stopped); + assert((await storage.shares.listShares(workspaceId)).length === 0, 'owner secrets crypto-erased'); + relay = null; + const recreated = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(recreated.shareId !== first.shareId && recreated.roomId !== first.roomId, 'recreate mints fresh ownership'); + } finally { storage.close(); } +}); + +test('scope and lease validation fail before network or stale mutation', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-v3-fence'; await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); let network = 0; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, randomBytes: deterministicRandom(), + createRoom: async options => { network += 1; return bootstrapFromOptions(options); }, + }); + for (const invalid of [request('entries', ['missing.md']), request('entries', ['assets/image.png'])]) { + let rejected = false; try { await coordinator.ensurePublished(invalid); } catch (error) { rejected = error instanceof BrowserStorageError; } + assert(rejected, 'invalid scope rejected'); + } + assert(network === 0, 'scope validation performs no relay work'); + let fenced = false; + try { await storage.shares.forgetShareFenced(workspaceId, 'missing', { holderId: 'other', fencingToken: fence.fencingToken }); } + catch (error) { fenced = error instanceof StorageConflictError; } + assert(fenced, 'passive tab is fenced'); + } finally { storage.close(); } +}); + +const results = await Promise.all(cases.map(run => run())); +for (const result of results) console.log(`${result.ok ? 'PASS' : 'FAIL'} ${result.name}${result.detail ? `\n${result.detail}` : ''}`); +const failures = results.filter(result => !result.ok); +console.log(`browser-workspace-sharing: ${results.length - failures.length} passed, ${failures.length} failed`); +if (failures.length) process.exit(1); diff --git a/web/src/lib/review/browser-workspace-sharing.ts b/web/src/lib/review/browser-workspace-sharing.ts new file mode 100644 index 00000000..722139ac --- /dev/null +++ b/web/src/lib/review/browser-workspace-sharing.ts @@ -0,0 +1,952 @@ +// Accountless browser-owner durable sharing (attn-7xl.4.5). +// +// A workspace owns one stable ShareDO id and three sibling public bearers. +// The ordinary v3 room is an epoch-scoped implementation detail: it can be +// recreated without changing `/s/#key=…` links. + +import { ed25519 } from '@noble/curves/ed25519.js'; +import type { AnchorIndex, Capability, ReviewEvent, RoomPolicy } from '../types'; +import { + aeadOpen, + base64UrlDecode, + base64UrlEncode, + deriveEventEnvelopeId, + deriveEventId, + deriveRoomIdV3, + deriveShareLinkKeys, + deriveShareEpochRoomSecret, + verifyEventSignature, +} from './browser-crypto'; +import { assembleBrowserEvent } from './browser-envelope'; +import { + createOwnedRoomV3, + defaultOwnerPolicy, + deleteOwnedRoom, + deleteOwnedRoomV3, + registerFrozenReviewerDeviceV3, + type CreateOwnedRoomOptions, + type OwnedRoomBootstrap, + type OwnedRoomBootstrapV3, +} from './browser-owner-bootstrap'; +import { BrowserOutbox, type BrowserOutboxPersistence } from './browser-outbox'; +import { validateBrowserRelayUrl } from './browser-relay-url'; +import { + canonicalDeviceGrantV3, + canonicalRegisterDeviceBytes, + generateBrowserIdentity, + ownerCredentialsFromInviteCapability, + ownerCredentialsV3FromInviteCapability, + verifyDeviceGrantV3, + type BrowserOwnerCredentials, + type BrowserOwnerCredentialsV3, + type RegisterDeviceBodyV3, +} from './browser-session'; +import { + publishBrowserSnapshots, + resumeBrowserSnapshotPublication, + type BrowserSnapshotEntry, + type PublishBrowserSnapshotsOptions, + type SnapshotPublicationOutbox, +} from './browser-snapshot-publisher'; +import { + BrowserShareOwnerRelayClient, + BrowserShareOwnerRelayError, + EMPTY_SHARE_MANIFEST_DIGEST, + buildShareBundleMutations, + composeShareTierInvites, + sealDurableShareSnapshot, + type BrowserShareOwnerRelayOptions, + type BrowserShareRelayRecord, + type BrowserShareMailboxPage, + type BrowserShareUpsertRequest, + type ManagedShareSnapshotRef, + type ShareTierInvites, +} from './browser-share-owner'; +import type { BrowserStorage } from './browser-storage'; +import { BrowserStorageError, StorageConflictError } from './browser-storage-errors'; +import { + inviteCapabilityFrom, + type DurableShareCapabilityState, + type InviteCapability, + type ShareRecordView, +} from './browser-workspace-share'; +import { compareManifestPathsUtf8 } from './browser-workspace-manifest'; +import { normalizeEntryPath, type ShareScopeKind } from './browser-workspace-schema'; +import type { LeaseHandle } from './browser-workspace-lease'; +import type { MailboxEnvelope } from './browser-ws'; + +export const BROWSER_SHARE_TTL_ONE_HOUR = 60 * 60 * 1000; +export const BROWSER_SHARE_TTL_ONE_DAY = 24 * BROWSER_SHARE_TTL_ONE_HOUR; +export const BROWSER_SHARE_TTL_SEVEN_DAYS = 7 * BROWSER_SHARE_TTL_ONE_DAY; + +export type BrowserWorkspaceShareMode = RoomPolicy['mode']; +export type BrowserWorkspaceShareTtlMs = + | typeof BROWSER_SHARE_TTL_ONE_HOUR + | typeof BROWSER_SHARE_TTL_ONE_DAY + | typeof BROWSER_SHARE_TTL_SEVEN_DAYS; + +export interface BrowserWorkspaceShareRequest { + relayUrl: string; + browserReviewBase: string; + scopeKind: ShareScopeKind; + paths: readonly string[]; + mode?: BrowserWorkspaceShareMode; + ttlMs?: BrowserWorkspaceShareTtlMs; + ownerDisplayName?: string; +} + +export interface BrowserWorkspaceShareView { + workspaceId: string; + capId: string; + shareId: string; + roomId: string; + scopeKind: ShareScopeKind; + paths: string[]; + publication: ShareRecordView['publication']; + mode: BrowserWorkspaceShareMode; + expiresAt: number; + expired: boolean; + resumable: boolean; + invite: ShareTierInvites | null; +} + +export interface BrowserWorkspaceShareOutbox extends SnapshotPublicationOutbox { + initialize(): Promise; + close(): void; +} + +export interface BrowserShareOwnerRelayPort { + upsert(request: BrowserShareUpsertRequest): Promise; + fetchWithViewCapability(shareSecret: Uint8Array): Promise; + uploadSnapshot(fileId: string, snapshotId: string, ciphertext: Uint8Array): Promise; + deleteSnapshot(fileId: string): Promise; + fetchMailbox(shareSecret: Uint8Array, tier: 'comment' | 'suggest', after: number): Promise; + ackMailbox(through: number): Promise; + revoke(): Promise; +} + +export interface BrowserWorkspaceSharingDependencies { + createRoom?: (options: CreateOwnedRoomOptions) => Promise; + deleteRoom?: typeof deleteOwnedRoomV3; + publish?: (options: PublishBrowserSnapshotsOptions) => Promise; + shareRelayFactory?: (options: BrowserShareOwnerRelayOptions) => BrowserShareOwnerRelayPort; + outboxFactory?: (input: { + storage: BrowserStorage; + relayUrl: string; + credentials: BrowserOwnerCredentialsV3; + }) => BrowserWorkspaceShareOutbox; + now?: () => number; + randomBytes?: (length: number) => Uint8Array; + indexBuilder?: (markdown: Uint8Array, snapshotId: string) => Promise; + registerFrozenDevice?: typeof registerFrozenReviewerDeviceV3; +} + +const OWNER_CAPABILITIES: Capability[] = [ + 'room_admin', 'read_snapshot', 'write_comment', 'write_suggestion', + 'resolve_comment', 'accept_suggestion', 'publish_snapshot', +]; + +export class BrowserWorkspaceSharingCoordinator { + private readonly now: () => number; + private readonly randomBytes: (length: number) => Uint8Array; + + constructor( + private readonly storage: BrowserStorage, + private readonly workspaceId: string, + private readonly fence: LeaseHandle, + private readonly dependencies: BrowserWorkspaceSharingDependencies = {}, + ) { + this.now = dependencies.now ?? Date.now; + this.randomBytes = dependencies.randomBytes + ?? ((length) => crypto.getRandomValues(new Uint8Array(length))); + } + + async inspect(_browserReviewBase: string): Promise { + const record = await this.inspectRecord(); + return record ? this.view(record) : null; + } + + /** Owner-tab startup reconciliation: renew routing and drain offline mail. */ + async reconcileActive(): Promise { + const record = await this.inspectRecord(); + if (!record) return null; + const rootKey = await this.requireRootKey(); + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const durable = capability.durableShare; + if (!durable || durable.lifecycle !== 'active' + || (durable.expiresAt !== undefined && durable.expiresAt <= this.timestamp())) { + return this.view(record); + } + return this.ensurePublished({ + relayUrl: record.relayUrl, + browserReviewBase: 'https://attn.sh', + scopeKind: record.scopeKind, + paths: capability.sharePaths ?? [], + mode: (capability.policy as RoomPolicy).mode, + }); + } + + async ensurePublished(request: BrowserWorkspaceShareRequest): Promise { + validateRequest(request); + const relayUrl = validateBrowserRelayUrl(request.relayUrl); + const rootKey = await this.requireRootKey(); + let record = await this.inspectRecord(); + + if (record) { + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + if (!capability.durableShare) { + // One-time migration from the checkpoint's v2 browser room. Retire it + // authoritatively before minting the stable v3 share. + const legacy = ownerCredentialsFromInviteCapability(capability, record.roomId); + try { + await deleteOwnedRoom({ + relayUrl: record.relayUrl, + roomId: record.roomId, + identity: legacy.identity, + admissionKey: legacy.keys.admissionKey, + }); + } finally { + zeroCredentials(legacy); + } + await this.eraseLocal(record); + record = null; + } else if ( + capability.durableShare.lifecycle === 'active' + && capability.durableShare.currentRoomId === record.roomId + && record.publication === 'published' + && (capability.durableShare.expiresAt ?? 0) > this.timestamp() + ) { + // Reasserting an active share is also its renewal path. Refresh the + // sealed ordinary-room policy before touching RoomDO so a missing + // 24-hour epoch room can be recreated under the same stable share. + const policy = { ...(capability.policy as RoomPolicy) }; + policy.expiresAt = this.timestamp() + BROWSER_SHARE_TTL_ONE_DAY; + record = await this.storage.shares.updateDurableShareFenced( + rootKey, + this.workspaceId, + record.capId, + capability.durableShare, + this.fence, + policy, + ); + } else if ( + capability.durableShare.lifecycle === 'revoke_pending' + || (capability.durableShare.expiresAt ?? Number.MAX_SAFE_INTEGER) <= this.timestamp() + ) { + await this.deleteRemote(); + await this.eraseLocal(record); + record = null; + } + } + + let capability: InviteCapability; + let credentials: BrowserOwnerCredentialsV3; + if (record) { + capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + credentials = ownerCredentialsV3FromInviteCapability(capability, record.roomId); + } else { + const paths = await this.resolvePaths(request.scopeKind, request.paths); + const createdAt = this.timestamp(); + const policy = defaultOwnerPolicy(createdAt); + policy.mode = request.mode ?? 'hybrid'; + // Stable links renew for 90 days; the current ordinary room stays short-lived. + policy.expiresAt = createdAt + BROWSER_SHARE_TTL_ONE_DAY; + const shareId = base64UrlEncode(this.exactRandom(16, 'share id')); + const shareSecret = this.exactRandom(32, 'share secret'); + const roomSecret = deriveShareEpochRoomSecret(shareSecret, 0); + const roomId = deriveRoomIdV3(roomSecret); + const identity = generateBrowserIdentity(); + const durableShare: DurableShareCapabilityState = { + protocolVersion: 3, + shareId, + shareSecret: base64UrlEncode(shareSecret), + epoch: 0, + revision: 0, + manifestDigest: EMPTY_SHARE_MANIFEST_DIGEST, + drainCursor: 0, + lifecycle: 'active', + }; + capability = inviteCapabilityFrom({ + roomSecret, + ownerSigningSecret: identity.signingSecret, + ownerEncryptionSecret: identity.encryptionSecret, + ownerDeviceId: identity.deviceId, + ownerParticipantId: identity.participantId, + durableShare, + policy, + sharePaths: paths, + }); + record = await this.storage.shares.bindShareFenced(rootKey, { + workspaceId: this.workspaceId, + capId: shareId, + roomId, + scopeKind: request.scopeKind, + relayUrl, + capability, + }, this.fence); + credentials = ownerCredentialsV3FromInviteCapability(capability, roomId); + shareSecret.fill(0); + roomSecret.fill(0); + identity.signingSecret.fill(0); + identity.encryptionSecret.fill(0); + } + + const paths = capability.sharePaths; + if (!paths?.length) { + zeroCredentialsV3(credentials); + throw new BrowserStorageError('prepared share is missing its exact scope paths'); + } + + const createRoom = this.dependencies.createRoom ?? createOwnedRoomV3; + const bootstrap = await createRoom({ + relayUrl: record.relayUrl, + policy: credentials.policy, + identity: credentials.identity, + roomSecret: credentials.roomSecret, + now: this.now, + }); + if (bootstrap.roomId !== record.roomId) { + zeroBootstrapKeys(bootstrap); + zeroCredentialsV3(credentials); + throw new StorageConflictError('relay room does not match prepared v3 ownership'); + } + const roomWasCreated = bootstrap.created; + zeroBootstrapKeys(bootstrap); + + const outbox = this.makeOutbox(record.relayUrl, credentials); + try { + await outbox.initialize(); + await this.drainDurableMailbox(rootKey, record, credentials, outbox); + if (capability.pendingPublication) { + await resumeBrowserSnapshotPublication(outbox, { + sink: this.storage.shares.publicationSink(rootKey), + workspaceId: this.workspaceId, + capId: record.capId, + fence: this.fence, + revisionSource: this.storage.workspaces, + }); + } else if (record.publication !== 'published' || roomWasCreated) { + const sources = await this.loadSources(paths); + try { + const genesisAt = Math.max(this.timestamp(), record.createdAt); + const publish = this.dependencies.publish ?? publishBrowserSnapshots; + await publish({ + protocolVersion: 3, + relayUrl: record.relayUrl, + roomId: record.roomId, + roomSecret: credentials.roomSecret, + keys: credentials.keys, + identity: credentials.identity, + policy: credentials.policy, + entries: sources, + prefixEnvelopes: ownerGenesisEnvelopes(credentials, request.ownerDisplayName, genesisAt), + scope: record.scopeKind, + outbox, + publication: { + sink: this.storage.shares.publicationSink(rootKey), + workspaceId: this.workspaceId, + capId: record.capId, + fence: this.fence, + revisionSource: this.storage.workspaces, + }, + now: () => Math.max(this.timestamp(), genesisAt + 2), + }); + } finally { + zeroSources(sources); + } + } + + const promoted = await this.inspectRecord(); + if (!promoted || promoted.capId !== record.capId || promoted.publication !== 'published') { + throw new StorageConflictError('ordinary v3 room publication did not promote'); + } + await this.publishDurableProjection(rootKey, promoted, credentials); + const active = await this.inspectRecord(); + if (!active) throw new StorageConflictError('durable share disappeared after promotion'); + return this.view(active); + } finally { + outbox.close(); + zeroCredentialsV3(credentials); + } + } + + async deleteRemote(): Promise { + let record = await this.inspectRecord(); + if (!record) throw new BrowserStorageError('workspace has no active share'); + const rootKey = await this.requireRootKey(); + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + if (!capability.durableShare) { + const legacy = ownerCredentialsFromInviteCapability(capability, record.roomId); + try { + const stopped = await deleteOwnedRoom({ + relayUrl: record.relayUrl, + roomId: record.roomId, + identity: legacy.identity, + admissionKey: legacy.keys.admissionKey, + }); + if (!stopped) throw new Error('The review room could not be stopped.'); + return record; + } finally { + zeroCredentials(legacy); + } + } + const credentials = ownerCredentialsV3FromInviteCapability(capability, record.roomId); + try { + const pending = { ...capability.durableShare, lifecycle: 'revoke_pending' as const }; + record = await this.storage.shares.updateDurableShareFenced( + rootKey, this.workspaceId, record.capId, pending, this.fence, + ); + const client = this.makeShareRelay(record.relayUrl, credentials); + await client.revoke(); + const stopped = await (this.dependencies.deleteRoom ?? deleteOwnedRoomV3)({ + relayUrl: record.relayUrl, + roomId: record.roomId, + identity: credentials.identity, + writeAdmissionKey: credentials.keys.admissionKey, + }); + if (!stopped) throw new Error('The stable link is revoked, but the epoch room teardown must be retried.'); + return record; + } finally { + zeroCredentialsV3(credentials); + } + } + + async eraseLocal(record: ShareRecordView): Promise { + if (record.workspaceId !== this.workspaceId) { + throw new StorageConflictError('share erase is bound to another workspace'); + } + await this.storage.shares.forgetShareFenced(this.workspaceId, record.capId, this.fence); + } + + private async publishDurableProjection( + rootKey: CryptoKey, + record: ShareRecordView, + credentials: BrowserOwnerCredentialsV3, + ): Promise { + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const durable = capability.durableShare; + if (!durable) throw new StorageConflictError('durable share ownership is missing'); + const manifest = capability.publishedManifest; + if (!manifest) throw new StorageConflictError('published room is missing its exact manifest'); + const client = this.makeShareRelay(record.relayUrl, credentials); + const context = this.bundleContext(credentials, 0, EMPTY_SHARE_MANIFEST_DIGEST); + let remote: BrowserShareRelayRecord; + try { + remote = await client.fetchWithViewCapability(credentials.shareSecret); + } catch (error) { + if (!(error instanceof BrowserShareOwnerRelayError) || error.status !== 404) throw error; + remote = await client.upsert({ + v: 3, + ownerSigningKey: base64UrlEncode(credentials.identity.signingPublic), + bundles: buildShareBundleMutations(context), + epoch: credentials.epoch, + revision: 0, + currentRoomId: null, + snapshots: [], + placeholders: [], + deviceId: credentials.identity.deviceId, + }); + } + + const sources = await this.loadSources(capability.sharePaths ?? []); + try { + const desired = new Map(); + for (const entry of manifest.entries) { + const source = sources.find((candidate) => candidate.path === entry.path); + if (!source) throw new StorageConflictError('durable share source path disappeared'); + // The current retained-snapshot plaintext format is text-bearing. + // Assets remain available through the live ordinary room; a later + // resolver extension may retain inert binary snapshots as well. + if (source.docType === 'asset') continue; + desired.set(entry.fileId, { snapshotId: entry.snapshotId, source }); + } + for (const retained of remote.snapshots) { + const wanted = desired.get(retained.fileId); + if (!wanted || wanted.snapshotId !== retained.snapshotId) { + await client.deleteSnapshot(retained.fileId); + } + } + remote = await client.fetchWithViewCapability(credentials.shareSecret); + for (const [fileId, wanted] of desired) { + if (remote.snapshots.some((candidate) => candidate.fileId === fileId && candidate.snapshotId === wanted.snapshotId)) { + continue; + } + const content = new TextDecoder('utf-8', { fatal: true }).decode(wanted.source.bytes); + if (wanted.source.docType === 'asset') { + throw new StorageConflictError('durable text projection selected an asset'); + } + const metadata = wanted.source.docType === 'markdown' + ? await (this.dependencies.indexBuilder + ?? (await import('./browser-anchor-index')).buildCanonicalAnchorIndex)( + wanted.source.bytes, + wanted.snapshotId, + ) + : undefined; + const sealed = sealDurableShareSnapshot({ + shareId: credentials.shareId, + epoch: credentials.epoch, + fileId, + snapshotId: wanted.snapshotId, + docType: wanted.source.docType, + content, + metadata, + snapshotKey: credentials.keys.snapshotKey, + }); + try { + await client.uploadSnapshot(fileId, wanted.snapshotId, sealed); + } finally { + sealed.fill(0); + } + } + } finally { + zeroSources(sources); + } + + remote = await client.fetchWithViewCapability(credentials.shareSecret); + const exact = remote.currentRoomId === record.roomId + && remote.epoch === credentials.epoch + && remote.revision === durable.revision + && remote.manifestDigest === durable.manifestDigest; + const revision = exact ? remote.revision : remote.revision + 1; + const active = await client.upsert({ + v: 3, + ownerSigningKey: base64UrlEncode(credentials.identity.signingPublic), + bundles: exact + ? [] + : buildShareBundleMutations( + this.bundleContext(credentials, revision, remote.manifestDigest), + ), + epoch: credentials.epoch, + revision, + currentRoomId: record.roomId, + snapshots: remote.snapshots, + placeholders: remote.placeholders, + deviceId: credentials.identity.deviceId, + }); + await this.storage.shares.updateDurableShareFenced(rootKey, this.workspaceId, record.capId, { + protocolVersion: 3, + shareId: credentials.shareId, + shareSecret: base64UrlEncode(credentials.shareSecret), + epoch: credentials.epoch, + revision: active.revision, + manifestDigest: active.manifestDigest, + currentRoomId: record.roomId, + expiresAt: active.expiresAt, + drainCursor: durable.drainCursor ?? 0, + lifecycle: 'active', + }, this.fence); + } + + private async drainDurableMailbox( + rootKey: CryptoKey, + record: ShareRecordView, + credentials: BrowserOwnerCredentialsV3, + outbox: BrowserWorkspaceShareOutbox, + ): Promise { + const client = this.makeShareRelay(record.relayUrl, credentials); + const remote = await client.fetchWithViewCapability(credentials.shareSecret).catch((error) => { + if (error instanceof BrowserShareOwnerRelayError && error.status === 404) return null; + throw error; + }); + if (!remote || remote.mailbox.count === 0) return; + const rootCapability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const durable = rootCapability.durableShare; + if (!durable) throw new StorageConflictError('durable share ownership is missing'); + const after = durable.drainCursor ?? 0; + const items = new Map(); + for (const tier of ['comment', 'suggest'] as const) { + let cursor = after; + for (;;) { + const page = await client.fetchMailbox(credentials.shareSecret, tier, cursor); + for (const item of page.items) { + if (items.has(item.seq)) throw new StorageConflictError('durable mailbox repeated a sequence'); + items.set(item.seq, item); + } + if (page.items.length === 0) break; + if (page.nextAfter <= cursor) throw new StorageConflictError('durable mailbox cursor did not advance'); + cursor = page.nextAfter; + } + } + if (items.size !== remote.mailbox.count) { + throw new StorageConflictError('durable mailbox selectors did not cover the retained prefix'); + } + const ordered = [...items.values()].sort((left, right) => left.seq - right.seq); + for (let index = 0; index < ordered.length; index += 1) { + if (ordered[index]!.seq !== after + index + 1) { + throw new StorageConflictError('durable mailbox sequence is not contiguous'); + } + } + const submissions = ordered.map((item) => preflightReviewSubmission(item, credentials, remote)); + for (const submission of submissions) { + await (this.dependencies.registerFrozenDevice ?? registerFrozenReviewerDeviceV3)({ + relayUrl: record.relayUrl, + roomId: credentials.roomId, + writeAdmissionKey: credentials.keys.admissionKey, + registration: submission.registration, + }); + await outbox.enqueueBatchDurably(submission.envelopes); + await outbox.flushNow(); + } + const through = ordered.at(-1)!.seq; + await client.ackMailbox(through); + await this.storage.shares.updateDurableShareFenced(rootKey, this.workspaceId, record.capId, { + ...durable, + drainCursor: through, + }, this.fence); + } + + private bundleContext( + credentials: BrowserOwnerCredentialsV3, + revision: number, + manifestDigest: string, + ) { + const signGrant = (tier: 'comment' | 'suggest'): string => base64UrlEncode( + ed25519.sign(canonicalDeviceGrantV3(credentials.roomId, tier), credentials.identity.signingSecret), + ); + return { + shareId: credentials.shareId, + shareSecret: credentials.shareSecret, + epoch: credentials.epoch, + revision, + manifestDigest, + roomId: credentials.roomId, + ownerSigningKey: base64UrlEncode(credentials.identity.signingPublic), + readCapabilityKey: credentials.readCapabilityKey!, + writeAdmissionKey: credentials.keys.admissionKey, + commentGrantSignature: signGrant('comment'), + suggestGrantSignature: signGrant('suggest'), + randomBytes: this.randomBytes, + }; + } + + private async inspectRecord(): Promise { + const active = (await this.storage.shares.listShares(this.workspaceId)) + .filter((share) => share.publication !== 'stopped'); + if (active.length > 1) throw new StorageConflictError('workspace has multiple active shares'); + return active[0] ?? null; + } + + private async view(record: ShareRecordView): Promise { + const rootKey = await this.requireRootKey(); + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const durable = capability.durableShare; + const published = record.publication === 'published' + && durable?.lifecycle === 'active' + && durable.currentRoomId === record.roomId; + const expiresAt = durable?.expiresAt ?? (capability.policy as RoomPolicy).expiresAt; + const expired = expiresAt <= this.timestamp(); + let invites: ShareTierInvites | null = null; + if (published && durable && !expired) { + const credentials = ownerCredentialsV3FromInviteCapability(capability, record.roomId); + try { + invites = composeShareTierInvites(durable.shareId, credentials.shareSecret); + } finally { + zeroCredentialsV3(credentials); + } + } + return { + workspaceId: this.workspaceId, + capId: record.capId, + shareId: durable?.shareId ?? record.capId, + roomId: record.roomId, + scopeKind: record.scopeKind, + paths: [...(capability.sharePaths ?? capability.publishedManifest?.entries.map((entry) => entry.path) ?? [])], + publication: record.publication, + mode: (capability.policy as RoomPolicy).mode, + expiresAt, + expired, + resumable: !published, + invite: invites, + }; + } + + private async resolvePaths(scopeKind: ShareScopeKind, requested: readonly string[]): Promise { + const entries = await this.storage.workspaces.listEntries(this.workspaceId); + const live = new Map(entries.map((entry) => [entry.path, entry])); + const paths = scopeKind === 'workspace' + ? entries.map((entry) => entry.path) + : requested.map((path) => normalizeEntryPath(path)); + if (scopeKind === 'file' && paths.length !== 1) { + throw new BrowserStorageError('current-file share requires exactly one path'); + } + if (paths.length === 0) throw new BrowserStorageError('share scope cannot be empty'); + if (new Set(paths).size !== paths.length) throw new BrowserStorageError('share scope contains duplicate paths'); + for (const path of paths) if (!live.has(path)) throw new StorageConflictError('share scope contains a stale path'); + if (!paths.some((path) => live.get(path)?.kind === 'markdown')) { + throw new BrowserStorageError('share scope must contain at least one Markdown file'); + } + return [...paths].sort(compareManifestPathsUtf8); + } + + private async loadSources(paths: readonly string[]): Promise { + const sources: BrowserSnapshotEntry[] = []; + try { + for (const path of paths) { + const entry = await this.storage.workspaces.getEntry(this.workspaceId, path); + if (!entry) throw new StorageConflictError('share scope changed before publication'); + const bytes = await this.storage.workspaces.getRevisionBody(this.workspaceId, path, entry.headRevisionId); + sources.push(entry.kind === 'markdown' + ? { path, docType: 'markdown', bytes, revisionId: entry.headRevisionId } + : { path, docType: 'asset', mediaType: entry.mediaType ?? 'application/octet-stream', bytes, revisionId: entry.headRevisionId }); + } + return sources; + } catch (error) { + zeroSources(sources); + throw error; + } + } + + private makeOutbox(relayUrl: string, credentials: BrowserOwnerCredentialsV3): BrowserWorkspaceShareOutbox { + if (this.dependencies.outboxFactory) { + return this.dependencies.outboxFactory({ storage: this.storage, relayUrl, credentials }); + } + const persistence: BrowserOutboxPersistence = { + loadPending: () => this.storage.listOutbox(credentials.roomId, credentials.identity.deviceId), + putPending: async (envelope) => { await this.storage.putOutbox(credentials.roomId, envelope); }, + putPendingBatch: async (envelopes) => { await this.storage.putOutboxBatch(credentials.roomId, envelopes); }, + acknowledge: async (batch, accepted) => { await this.storage.acknowledge(credentials.roomId, batch, accepted); }, + }; + return new BrowserOutbox({ + relayUrl, + roomId: credentials.roomId, + deviceId: credentials.identity.deviceId, + admissionKey: credentials.keys.admissionKey, + protocolVersion: 3, + powBits: credentials.policy.powBits, + maxEventBytes: credentials.policy.maxEventBytes, + maxSnapshotBytes: credentials.policy.maxSnapshotBytes, + persistence, + }); + } + + private makeShareRelay(relayUrl: string, credentials: BrowserOwnerCredentialsV3): BrowserShareOwnerRelayPort { + const options = { + relayUrl, + shareId: credentials.shareId, + identity: credentials.identity, + }; + return this.dependencies.shareRelayFactory?.(options) + ?? new BrowserShareOwnerRelayClient(options); + } + + private async requireRootKey(): Promise { + const key = await this.storage.getWorkspaceRootKey(this.workspaceId); + if (!key) throw new BrowserStorageError('workspace key is unavailable'); + return key; + } + + private timestamp(): number { + const value = this.now(); + if (!Number.isSafeInteger(value) || value <= 0) throw new BrowserStorageError('share clock is invalid'); + return value; + } + + private exactRandom(length: number, label: string): Uint8Array { + const value = this.randomBytes(length); + if (!(value instanceof Uint8Array) || value.length !== length) { + throw new BrowserStorageError(`${label} generator returned the wrong length`); + } + return new Uint8Array(value); + } +} + +function ownerGenesisEnvelopes(credentials: BrowserOwnerCredentials, displayName: string | undefined, createdAt: number) { + const common = { + eventKey: credentials.keys.eventKey, + signingSecret: credentials.identity.signingSecret, + signingPublic: credentials.identity.signingPublic, + roomId: credentials.roomId, + authorId: credentials.identity.participantId, + deviceId: credentials.identity.deviceId, + expiresAt: credentials.policy.expiresAt, + } as const; + const roomCreated = assembleBrowserEvent({ ...common, createdAt, body: { + type: 'room_created', roomId: credentials.roomId, policy: credentials.policy, + createdBy: credentials.identity.participantId, + } }); + const ownerJoined = assembleBrowserEvent({ ...common, createdAt: createdAt + 1, body: { + type: 'participant_joined', + participant: { + participantId: credentials.identity.participantId, + displayName: displayName?.trim() || 'Browser owner', + kind: 'owner', publicSigningKey: base64UrlEncode(credentials.identity.signingPublic), + capabilities: [...OWNER_CAPABILITIES], + }, + device: { + deviceId: credentials.identity.deviceId, + participantId: credentials.identity.participantId, + publicEncryptionKey: base64UrlEncode(credentials.identity.publicEncryptionKey), + publicSigningKey: base64UrlEncode(credentials.identity.signingPublic), + client: 'attn-browser', createdAt: createdAt + 1, + }, + } }); + return [roomCreated.envelope, ownerJoined.envelope] as const; +} + +function validateRequest(request: BrowserWorkspaceShareRequest): void { + if (request.mode !== undefined && !['live', 'async', 'hybrid'].includes(request.mode)) { + throw new BrowserStorageError('share mode is invalid'); + } + if (request.ttlMs !== undefined && ![ + BROWSER_SHARE_TTL_ONE_HOUR, BROWSER_SHARE_TTL_ONE_DAY, BROWSER_SHARE_TTL_SEVEN_DAYS, + ].includes(request.ttlMs)) throw new BrowserStorageError('share lifetime is invalid'); + if (!['file', 'entries', 'workspace'].includes(request.scopeKind)) { + throw new BrowserStorageError('share scope is invalid'); + } +} + +function preflightReviewSubmission( + item: BrowserShareMailboxPage['items'][number], + credentials: BrowserOwnerCredentialsV3, + remote: BrowserShareRelayRecord, +): { registration: RegisterDeviceBodyV3; envelopes: MailboxEnvelope[] } { + if (!isRecord(item.payload)) throw new StorageConflictError('durable mailbox payload is invalid'); + const payload = item.payload; + if (payload.v !== 3 || payload.type !== 'review_submission' + || payload.envelopeId !== item.envelopeId || payload.shareId !== credentials.shareId + || payload.epoch !== credentials.epoch || payload.roomId !== credentials.roomId + || payload.tier !== item.tier || item.epoch !== credentials.epoch + || !Array.isArray(payload.envelopes) || payload.envelopes.length < 2 || payload.envelopes.length > 8 + || !isRecord(payload.deviceRegistration)) { + throw new StorageConflictError('durable review submission routing is invalid'); + } + const keys = deriveShareLinkKeys(credentials.shareSecret, item.tier); + try { + if (keys.bundleId !== item.bundleId) { + throw new StorageConflictError('durable review submission selected the wrong sibling bearer'); + } + } finally { + keys.linkSecret.fill(0); + keys.bundleKey.fill(0); + keys.readAdmissionKey.fill(0); + keys.writeAdmissionKey?.fill(0); + } + const raw = payload.deviceRegistration; + if (typeof raw.deviceId !== 'string' || typeof raw.participantId !== 'string' + || typeof raw.publicSigningKey !== 'string' || typeof raw.publicEncryptionKey !== 'string' + || raw.client !== 'attn-browser' || raw.kind !== 'reviewer' || raw.grantTier !== item.tier + || typeof raw.grantSignature !== 'string' || typeof raw.selfSignature !== 'string') { + throw new StorageConflictError('durable review submission registration is invalid'); + } + const registration = raw as unknown as RegisterDeviceBodyV3; + const publicKey = base64UrlDecode(registration.publicSigningKey); + const encryptionKey = base64UrlDecode(registration.publicEncryptionKey); + const selfSignature = base64UrlDecode(registration.selfSignature); + try { + if (publicKey.length !== 32 || encryptionKey.length !== 32 || selfSignature.length !== 64 + || !ed25519.verify(selfSignature, canonicalRegisterDeviceBytes(registration), publicKey) + || !verifyDeviceGrantV3( + credentials.roomId, + registration.grantTier, + registration.grantSignature, + remote.ownerSigningKey, + )) { + throw new StorageConflictError('durable review submission registration proof is invalid'); + } + const envelopes = payload.envelopes.map((value, index) => { + if (!isRecord(value) || value.v !== 2 || value.kind !== 'event' + || value.roomId !== credentials.roomId || value.deviceId !== registration.deviceId + || value.authorId !== registration.participantId || typeof value.envelopeId !== 'string' + || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.expiresAt) + || typeof value.nonce !== 'string' || typeof value.ciphertext !== 'string' + || !Number.isSafeInteger(value.ciphertextBytes)) { + throw new StorageConflictError('durable review submission envelope is invalid'); + } + const envelope = value as unknown as MailboxEnvelope; + const plaintext = aeadOpen( + credentials.keys.eventKey, + base64UrlDecode(envelope.nonce), + base64UrlDecode(envelope.ciphertext), + { + v: 2, + roomId: credentials.roomId, + envelopeId: envelope.envelopeId, + kind: 'event', + authorId: envelope.authorId, + deviceId: envelope.deviceId, + createdAt: envelope.createdAt, + }, + ); + try { + const event = JSON.parse(new TextDecoder().decode(plaintext)) as ReviewEvent; + if (!isRecord(event) || !isRecord(event.meta) || !isRecord(event.auth) + || event.meta.v !== 2 || event.meta.roomId !== credentials.roomId + || event.meta.authorId !== registration.participantId + || event.meta.deviceId !== registration.deviceId + || event.meta.createdAt !== envelope.createdAt + || typeof event.meta.eventId !== 'string' + || deriveEventEnvelopeId(credentials.roomId, event.meta.eventId) !== envelope.envelopeId + || deriveEventId(event.meta, event.body) !== event.meta.eventId) { + throw new StorageConflictError('durable review event binding is invalid'); + } + verifyEventSignature(event.meta, event.body, event.auth, publicKey); + if (index === 0) { + if (event.body.type !== 'participant_joined' + || event.body.participant.participantId !== registration.participantId + || event.body.participant.kind !== 'reviewer' + || event.body.participant.publicSigningKey !== registration.publicSigningKey + || event.body.device.deviceId !== registration.deviceId + || event.body.device.publicSigningKey !== registration.publicSigningKey + || event.body.device.publicEncryptionKey !== registration.publicEncryptionKey) { + throw new StorageConflictError('durable reviewer attestation event is invalid'); + } + } else if (event.body.type !== 'comment_created' && event.body.type !== 'suggestion_created') { + throw new StorageConflictError('durable review submission contains an unauthorized event'); + } + } finally { + plaintext.fill(0); + } + return structuredClone(envelope); + }); + return { registration: structuredClone(registration), envelopes }; + } finally { + publicKey.fill(0); + encryptionKey.fill(0); + selfSignature.fill(0); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function zeroSources(sources: readonly BrowserSnapshotEntry[]): void { + for (const source of sources) source.bytes.fill(0); +} + +function zeroCredentials(credentials: BrowserOwnerCredentials): void { + credentials.roomSecret.fill(0); + credentials.keys.rootKey.fill(0); + credentials.keys.eventKey.fill(0); + credentials.keys.snapshotKey.fill(0); + credentials.keys.signalingKey.fill(0); + credentials.keys.admissionKey.fill(0); + credentials.readAdmissionKey?.fill(0); + credentials.readCapabilityKey?.fill(0); + credentials.identity.signingSecret.fill(0); + credentials.identity.signingPublic.fill(0); + credentials.identity.encryptionSecret.fill(0); + credentials.identity.publicEncryptionKey.fill(0); +} + +function zeroCredentialsV3(credentials: BrowserOwnerCredentialsV3): void { + credentials.shareSecret.fill(0); + zeroCredentials(credentials); +} + +function zeroBootstrapKeys(bootstrap: OwnedRoomBootstrapV3 | OwnedRoomBootstrap): void { + bootstrap.keys.rootKey.fill(0); + if ('readKeys' in bootstrap.keys) { + bootstrap.keys.readKeys.readCapabilityKey.fill(0); + bootstrap.keys.readKeys.eventKey.fill(0); + bootstrap.keys.readKeys.snapshotKey.fill(0); + bootstrap.keys.readKeys.signalingKey.fill(0); + bootstrap.keys.readKeys.readAdmissionKey.fill(0); + bootstrap.keys.writeAdmissionKey.fill(0); + } else { + bootstrap.keys.eventKey.fill(0); + bootstrap.keys.snapshotKey.fill(0); + bootstrap.keys.signalingKey.fill(0); + bootstrap.keys.admissionKey.fill(0); + } +}