From cd6ffa75ba943eee542682c3fa752dd23cca5a04 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sat, 11 Jul 2026 07:53:02 -0600 Subject: [PATCH 1/4] checkpoint: complete v2 browser share sheet --- src/daemon.rs | 15 +- src/review/bootstrap.rs | 9 +- src/review/manager.rs | 10 +- web/e2e/hosted-a11y.spec.ts | 6 +- web/e2e/hosted-authoring.spec.ts | 12 +- web/e2e/hosted-share-sheet.spec.ts | 174 +++++ web/e2e/hosted-shells.spec.ts | 13 +- web/playwright.routes.config.ts | 1 + web/src/hosted/app/EditorShell.svelte | 71 +- web/src/hosted/app/ShareSheet.svelte | 725 +++++++++++++++--- web/src/hosted/app/app-shell.css | 586 +++++++++++++- web/src/hosted/app/mock-service.ts | 69 +- web/src/hosted/app/real-service.ts | 33 + web/src/hosted/app/share-sheet-model.test.ts | 109 +++ web/src/hosted/app/share-sheet-model.ts | 176 +++++ web/src/hosted/app/types.ts | 41 + web/src/lib/review/browser-invite.test.ts | 104 ++- web/src/lib/review/browser-invite.ts | 77 +- .../review/browser-owner-bootstrap.test.ts | 34 + web/src/lib/review/browser-owner-bootstrap.ts | 15 +- .../browser-owner-workspace-runtime.test.ts | 280 +++++++ .../review/browser-owner-workspace-runtime.ts | 253 ++++-- web/src/lib/review/browser-session.test.ts | 28 +- .../review/browser-snapshot-publisher.test.ts | 52 ++ .../lib/review/browser-snapshot-publisher.ts | 16 +- .../review/browser-workspace-share.test.ts | 50 +- web/src/lib/review/browser-workspace-share.ts | 125 +++ .../review/browser-workspace-sharing.test.ts | 691 +++++++++++++++++ .../lib/review/browser-workspace-sharing.ts | 539 +++++++++++++ 29 files changed, 4028 insertions(+), 286 deletions(-) create mode 100644 web/e2e/hosted-share-sheet.spec.ts create mode 100644 web/src/hosted/app/share-sheet-model.test.ts create mode 100644 web/src/hosted/app/share-sheet-model.ts create mode 100644 web/src/lib/review/browser-workspace-sharing.test.ts create mode 100644 web/src/lib/review/browser-workspace-sharing.ts diff --git a/src/daemon.rs b/src/daemon.rs index 59308e02..9328c62a 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. @@ -1447,6 +1451,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: 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..15c6ace3 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -3522,9 +3522,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 +5948,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 +5957,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/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..62ea029c --- /dev/null +++ b/web/e2e/hosted-share-sheet.spec.ts @@ -0,0 +1,174 @@ +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('banner').getByRole('button', { name: 'Share', exact: true }).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, 24-hour hybrid 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('Access and lifetime Hybrid · 24 hours')).toBeVisible(); + + await dialog.getByText('Access and lifetime Hybrid · 24 hours').click(); + await expect(dialog.getByRole('radio', { name: /^Hybrid/u })).toBeChecked(); + await expect(dialog.getByRole('radio', { name: '24 hours' })).toBeChecked(); +}); + +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', exact: true }).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', exact: true }).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('browser, native, and CLI forms carry one secret; copy, stop, and recreate work', 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); + + 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()) ?? ''; + + await dialog.getByText('Native app and CLI options').click(); + const inviteCodes = dialog.locator('.share-invite-option code'); + const nativeUrl = (await inviteCodes.nth(0).textContent()) ?? ''; + const cliCommand = (await inviteCodes.nth(1).textContent()) ?? ''; + const browserSecret = new URL(browserUrl).hash; + const nativeSecret = new URL(nativeUrl).hash; + expect(nativeSecret).toBe(browserSecret); + expect(cliCommand).toContain(nativeUrl); + await expect(dialog.getByRole('link', { name: 'Open in attn' })).toHaveAttribute('href', nativeUrl); + await expect(dialog.locator('[role="status"]')).toHaveCount(1); + await expect(dialog.getByRole('link', { name: 'Open in attn' })).toHaveAttribute('href', nativeUrl); + await expect(dialog.getByRole('status')).toHaveCount(1); + + await dialog.getByRole('button', { name: 'Copy browser link' }).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 link' }).click(); + const payload = await page.evaluate(() => + (globalThis as typeof globalThis & { __attnShared?: ShareData }).__attnShared, + ); + expect(payload?.url).toMatch(/^https:\/\/attn\.sh\/review\/.+#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 link' }).click(); + const copied = await page.evaluate(() => + (globalThis as typeof globalThis & { __attnCopied?: string }).__attnCopied, + ); + expect(copied).toMatch(/^https:\/\/attn\.sh\/review\/.+#key=.+/u); + await expect(dialog.getByRole('status').filter({ hasText: /browser link was copied/u }).first()).toBeVisible(); +}); + +test('mobile lifetime choice rows meet 44px touch targets', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }); + const dialog = await openShare(page); + await dialog.getByText('Access and lifetime Hybrid · 24 hours').click(); + const ttlHeights = await dialog.locator('.share-ttl-options label').evaluateAll((labels) => + labels.map((label) => label.getBoundingClientRect().height), + ); + for (const height of ttlHeights) 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/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/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..13a2abdc 100644 --- a/web/src/hosted/app/ShareSheet.svelte +++ b/web/src/hosted/app/ShareSheet.svelte @@ -1,177 +1,654 @@ - -
- - + + diff --git a/web/src/hosted/app/app-shell.css b/web/src/hosted/app/app-shell.css index 149425d7..ab4d3f51 100644 --- a/web/src/hosted/app/app-shell.css +++ b/web/src/hosted/app/app-shell.css @@ -921,6 +921,520 @@ 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(--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(--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(--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(--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(--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(--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(--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(--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-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(--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(--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 +1589,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/mock-service.ts b/web/src/hosted/app/mock-service.ts index 02fa5dcd..a8389425 100644 --- a/web/src/hosted/app/mock-service.ts +++ b/web/src/hosted/app/mock-service.ts @@ -15,10 +15,18 @@ import type { StorageHealth, WorkspaceAppService, WorkspaceDetail, + WorkspaceShareView, WorkspaceSummary, } from './types'; import type { BrowserOwnerWorkspaceRuntimeState } from '../../lib/review/browser-owner-workspace-runtime'; +const MOCK_INVITE = { + roomId: 'yPJpJifC1HUQgHsJ_7speQ', + browserUrl: 'https://attn.sh/review/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc', + nativeUrl: 'attn://review/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc', + cliCommand: "npx attnmd review join 'attn://review/yPJpJifC1HUQgHsJ_7speQ#key=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc'", +} 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 +51,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 +77,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 +93,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 +103,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 +186,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 +210,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 +224,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 +331,32 @@ 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', + roomId: invite.roomId, + 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 +374,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..198b3627 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,41 @@ 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 WorkspaceShareInvite { + roomId: string; + browserUrl: string; + nativeUrl: string; + cliCommand: string; +} + +export interface WorkspaceShareView { + workspaceId: string; + capId: 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 +162,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/lib/review/browser-invite.test.ts b/web/src/lib/review/browser-invite.test.ts index 4f7e8ba3..5f51f72d 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(browser.roomSecret, native.roomSecret, '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..28d95844 100644 --- a/web/src/lib/review/browser-owner-bootstrap.test.ts +++ b/web/src/lib/review/browser-owner-bootstrap.test.ts @@ -166,6 +166,24 @@ defineCase('create sends the exact native wire shape with verifiable headers', a assertEqual(result.created, true, '201 => created'); }); +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 +265,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..d2c80bb3 100644 --- a/web/src/lib/review/browser-owner-bootstrap.ts +++ b/web/src/lib/review/browser-owner-bootstrap.ts @@ -71,7 +71,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 +80,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, @@ -102,6 +102,8 @@ export interface OwnedRoomBootstrap { 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 +132,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), }; @@ -275,5 +277,10 @@ 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; } 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..5d58cf74 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.test.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.test.ts @@ -1,4 +1,5 @@ import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { sha256 } from '@noble/hashes/sha2.js'; import { assembleBrowserEvent, type AssembledBrowserEvent } from './browser-envelope'; import { @@ -12,6 +13,7 @@ import { type BrowserOwnerWorkspaceAuthority, type BrowserOwnerWorkspaceRuntimeOptions, } from './browser-owner-workspace-runtime'; +import type { CreateOwnedRoomOptions, OwnedRoomBootstrap } from './browser-owner-bootstrap'; import type { BrowserOwnerAuthorityFile, BrowserOwnerAuthorityOptions, @@ -21,7 +23,15 @@ 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 type { + BrowserWorkspaceShareOutbox, + BrowserWorkspaceShareRequest, +} from './browser-workspace-sharing'; import type { MailboxEnvelope, RoomPolicy } from './browser-ws'; import type { Anchor, ReviewEvent } from '../types'; @@ -79,6 +89,89 @@ function opaque(fill: number): string { return base64UrlEncode(new Uint8Array(16).fill(fill)); } +function bootstrapFromOptions(options: CreateOwnedRoomOptions): OwnedRoomBootstrap { + 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: deriveRoomId(roomSecret), + roomSecret, + keys: deriveRoomKeys(roomSecret), + identity: options.identity, + policy: options.policy, + created: true, + }; +} + +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: async (markdown) => ({ + docHash: base64UrlEncode(sha256(markdown)), + canonicalEncoding: 'utf8-bytes', + lineCount: new TextDecoder().decode(markdown).split('\n').length, + blocks: [], + headings: [], + }), + }); +} + async function seedLocal(storage: BrowserStorage, workspaceId: string, text = 'hello') { return storage.workspaces.createWorkspace({ workspaceId, @@ -174,6 +267,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 +451,192 @@ 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, + 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); + }, + 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(); + equal(second.getState().roomId, null, 'unpromoted share does not start authority on route open'); + const resumed = await second.ensureShare(shareRequest()); + equal(createCalls, 1, 'persisted resume does not recreate the 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 only after 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, + 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..1d88d83b 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.ts @@ -47,6 +47,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 +113,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 +141,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; @@ -276,71 +293,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 +340,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 +582,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<{ @@ -668,13 +765,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]!; diff --git a/web/src/lib/review/browser-session.test.ts b/web/src/lib/review/browser-session.test.ts index a4e05f6a..decd32db 100644 --- a/web/src/lib/review/browser-session.test.ts +++ b/web/src/lib/review/browser-session.test.ts @@ -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}`, @@ -2348,7 +2348,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 +2368,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', 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..5cacca87 100644 --- a/web/src/lib/review/browser-snapshot-publisher.ts +++ b/web/src/lib/review/browser-snapshot-publisher.ts @@ -115,6 +115,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 +198,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); @@ -443,6 +447,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-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..9bde811e 100644 --- a/web/src/lib/review/browser-workspace-share.ts +++ b/web/src/lib/review/browser-workspace-share.ts @@ -59,6 +59,8 @@ export interface InviteCapability { ownerParticipantId: string; /** 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. */ @@ -198,6 +200,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); @@ -740,6 +822,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); @@ -767,6 +870,7 @@ export function inviteCapabilityFrom(input: { ownerDeviceId: string; ownerParticipantId: string; policy: unknown; + sharePaths?: string[]; publishedRevisionId?: string; publishedManifest?: PublishedManifestPointer; }): InviteCapability { @@ -778,6 +882,7 @@ export function inviteCapabilityFrom(input: { ownerDeviceId: input.ownerDeviceId, ownerParticipantId: input.ownerParticipantId, policy: input.policy, + ...(input.sharePaths === undefined ? {} : { sharePaths: validateSharePaths(input.sharePaths) }), ...(input.publishedRevisionId === undefined ? {} : { publishedRevisionId: input.publishedRevisionId }), @@ -823,8 +928,12 @@ 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.publishedManifest === undefined ? {} : { publishedManifest: validatePublishedManifest(parsed.publishedManifest) }), @@ -834,6 +943,22 @@ function validateInviteCapability(value: unknown): InviteCapability { }; } +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..12f4d8f7 --- /dev/null +++ b/web/src/lib/review/browser-workspace-sharing.test.ts @@ -0,0 +1,691 @@ +import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { + aeadOpen, + base64UrlDecode, + base64UrlEncode, + deriveRoomId, + deriveRoomKeys, +} from './browser-crypto'; +import type { CreateOwnedRoomOptions, OwnedRoomBootstrap } from './browser-owner-bootstrap'; +import { + publishBrowserSnapshots, + type BrowserSnapshotEntry, + type PublishBrowserSnapshotsOptions, +} from './browser-snapshot-publisher'; +import { BrowserStorage } from './browser-storage'; +import { BrowserStorageError, StorageConflictError } from './browser-storage-errors'; +import type { LeaseHandle } from './browser-workspace-lease'; +import { + BrowserWorkspaceSharingCoordinator, + type BrowserWorkspaceShareOutbox, + type BrowserWorkspaceShareRequest, + type BrowserWorkspaceSharingDependencies, +} 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 equal(actual: unknown, expected: unknown, message: string): void { + if (stableJson(actual) !== stableJson(expected)) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +async function rejectsConflict(run: () => Promise, message: string): Promise { + try { + await run(); + } catch (error) { + if (error instanceof StorageConflictError) return; + throw new Error(`${message}: expected StorageConflictError, got ${String(error)}`); + } + throw new Error(`${message}: expected rejection`); +} + +let databaseCounter = 0; +const NOW = 1_720_000_000_000; + +async function openStorage(): Promise { + databaseCounter += 1; + return BrowserStorage.open({ + indexedDB: new IDBFactory(), + databaseName: `browser-workspace-sharing-${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: 'assets/archive.bin', + kind: 'asset', + mediaType: 'application/octet-stream', + body: new Uint8Array([0, 1, 2, 255]), + }); + await storage.workspaces.createEntry({ + workspaceId, + path: 'appendix.md', + kind: 'markdown', + body: new TextEncoder().encode('Appendix\n'), + }); + await storage.workspaces.createEntry({ + workspaceId, + path: 'caf\u00e9.md', + kind: 'markdown', + body: new TextEncoder().encode('Unicode path\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 bootstrapFromOptions(options: CreateOwnedRoomOptions): OwnedRoomBootstrap { + assert(options.roomSecret, 'coordinator supplies prepared room secret'); + assert(options.identity, 'coordinator supplies prepared owner identity'); + assert(options.policy, 'coordinator supplies prepared room policy'); + const roomSecret = new Uint8Array(options.roomSecret); + return { + roomId: deriveRoomId(roomSecret), + roomSecret, + keys: deriveRoomKeys(roomSecret), + identity: options.identity, + policy: options.policy, + created: true, + }; +} + +class AckingOutbox implements BrowserWorkspaceShareOutbox { + readonly envelopes: MailboxEnvelope[] = []; + initialized = false; + closed = false; + + constructor( + private readonly storage: BrowserStorage, + private readonly roomId: string, + private readonly failFlush: boolean, + private readonly beforeAck?: () => Promise, + ) {} + + async initialize(): Promise { + this.initialized = true; + } + + async enqueueBatchDurably(envelopes: readonly MailboxEnvelope[]): Promise { + let inserted = 0; + for (const envelope of envelopes) { + const existing = this.envelopes.find((candidate) => candidate.envelopeId === envelope.envelopeId); + if (existing) { + equal(existing, envelope, 'an adopted pending envelope remains byte-exact'); + } else { + this.envelopes.push(structuredClone(envelope)); + inserted += 1; + } + } + return inserted; + } + + async flushNow(): Promise { + await this.beforeAck?.(); + 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.closed = 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); + } +} + +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.example/review', + scopeKind, + paths, + ownerDisplayName: ' Workspace owner ', + }; +} + +test('prepares ownership before relay I/O and publishes exact owner genesis first', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-genesis'; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let createOptions: CreateOwnedRoomOptions | undefined; + let publishOptions: PublishBrowserSnapshotsOptions | undefined; + const outboxes: AckingOutbox[] = []; + let coordinator: BrowserWorkspaceSharingCoordinator; + coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, + randomBytes: deterministicRandom(), + createRoom: async (options) => { + assert(options.roomSecret, 'coordinator supplies prepared room secret'); + assert(options.identity, 'coordinator supplies prepared owner identity'); + createOptions = { + ...options, + roomSecret: new Uint8Array(options.roomSecret), + identity: { + ...options.identity, + signingSecret: new Uint8Array(options.identity.signingSecret), + signingPublic: new Uint8Array(options.identity.signingPublic), + encryptionSecret: new Uint8Array(options.identity.encryptionSecret), + publicEncryptionKey: new Uint8Array(options.identity.publicEncryptionKey), + }, + }; + const prepared = await storage.shares.listShares(workspaceId); + equal(prepared.length, 1, 'prepared record exists before first network request'); + equal(prepared[0]?.publication, 'pending', 'prepared record is not exposed as published'); + const rootKey = await storage.getWorkspaceRootKey(workspaceId); + assert(rootKey, 'workspace root key exists'); + const capability = await storage.shares.openShare(rootKey, workspaceId, prepared[0]!.capId); + equal( + capability.sharePaths, + ['assets/image.png', 'notes/main.md'], + 'exact selected scope is sealed before relay I/O', + ); + return bootstrapFromOptions(options); + }, + publish: async (options) => { + publishOptions = options; + return publishBrowserSnapshots({ ...options, indexBuilder }); + }, + outboxFactory: ({ storage: outboxStorage, credentials }) => { + const outbox = new AckingOutbox(outboxStorage, credentials.roomId, false, async () => { + const pending = await coordinator.inspect('https://attn.example/review'); + assert(pending, 'prepared share remains inspectable during publish'); + equal(pending.publication, 'pending', 'relay ACK is the promotion boundary'); + equal(pending.invite, null, 'pending publication does not expose an invite'); + }); + outboxes.push(outbox); + return outbox; + }, + }); + + const view = await coordinator.ensurePublished( + request('entries', ['notes/main.md', 'assets/image.png']), + ); + assert(createOptions?.identity, 'relay create observed owner identity'); + assert(createOptions.roomSecret, 'relay create observed room secret'); + assert(publishOptions, 'publisher invoked'); + assert(view.invite, 'invite appears only after durable ACK promotion'); + equal(view.publication, 'published', 'share promoted after ACK'); + equal(view.paths, ['assets/image.png', 'notes/main.md'], 'published view retains exact scope'); + + const envelopes = outboxes[0]?.envelopes ?? []; + equal( + envelopes.slice(0, 3).map((envelope) => envelope.kind), + ['event', 'event', 'snapshot_blob'], + 'RoomCreated and owner ParticipantJoined precede every snapshot', + ); + const eventKey = deriveRoomKeys(createOptions.roomSecret).eventKey; + const roomCreated = decryptEvent(envelopes[0]!, eventKey); + const ownerJoined = decryptEvent(envelopes[1]!, eventKey); + equal(roomCreated.body, { + type: 'room_created', + roomId: view.roomId, + policy: createOptions.policy, + createdBy: createOptions.identity.participantId, + }, 'RoomCreated body exactly binds prepared policy and owner'); + equal(ownerJoined.body, { + type: 'participant_joined', + participant: { + participantId: createOptions.identity.participantId, + displayName: 'Workspace owner', + kind: 'owner', + publicSigningKey: base64UrlEncode(createOptions.identity.signingPublic), + capabilities: [ + 'room_admin', + 'read_snapshot', + 'write_comment', + 'write_suggestion', + 'resolve_comment', + 'accept_suggestion', + 'publish_snapshot', + ], + }, + device: { + deviceId: createOptions.identity.deviceId, + participantId: createOptions.identity.participantId, + publicEncryptionKey: base64UrlEncode(createOptions.identity.publicEncryptionKey), + publicSigningKey: base64UrlEncode(createOptions.identity.signingPublic), + client: 'attn-browser', + createdAt: NOW + 1, + }, + }, 'owner ParticipantJoined body exactly advertises native authority'); + eventKey.fill(0); + } finally { + storage.close(); + } +}); + +test('materializes current, selected, and whole-workspace scopes with mixed assets', async () => { + const scenarios: Array<{ + scope: BrowserWorkspaceShareRequest['scopeKind']; + paths: string[]; + expected: Array<[string, BrowserSnapshotEntry['docType'], string | undefined]>; + }> = [ + { + scope: 'file', + paths: ['notes/main.md'], + expected: [['notes/main.md', 'markdown', undefined]], + }, + { + scope: 'entries', + paths: ['notes/main.md', 'assets/archive.bin', 'assets/image.png', 'cafe\u0301.md'], + expected: [ + ['assets/archive.bin', 'asset', 'application/octet-stream'], + ['assets/image.png', 'asset', 'image/png'], + ['caf\u00e9.md', 'markdown', undefined], + ['notes/main.md', 'markdown', undefined], + ], + }, + { + scope: 'workspace', + paths: [], + expected: [ + ['appendix.md', 'markdown', undefined], + ['assets/archive.bin', 'asset', 'application/octet-stream'], + ['assets/image.png', 'asset', 'image/png'], + ['caf\u00e9.md', 'markdown', undefined], + ['notes/main.md', 'markdown', undefined], + ], + }, + ]; + + for (const [index, scenario] of scenarios.entries()) { + const storage = await openStorage(); + try { + const workspaceId = `ws-scope-${index}`; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let captured: Array<[string, BrowserSnapshotEntry['docType'], string | undefined]> = []; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, + randomBytes: deterministicRandom(), + createRoom: async (options) => bootstrapFromOptions(options), + publish: async (options) => { + captured = options.entries.map((entry) => [ + entry.path, + entry.docType, + entry.docType === 'asset' ? entry.mediaType : undefined, + ]); + return publishBrowserSnapshots({ ...options, indexBuilder }); + }, + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingOutbox(outboxStorage, credentials.roomId, false), + }); + const view = await coordinator.ensurePublished(request(scenario.scope, scenario.paths)); + equal(captured, scenario.expected, `${scenario.scope} source materialization`); + equal(view.paths, scenario.expected.map(([path]) => path), `${scenario.scope} sealed paths`); + } finally { + storage.close(); + } + } +}); + +test('resumes the exact pending ciphertext without recreating or re-encrypting', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-resume'; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let creates = 0; + let publishes = 0; + const outboxes: AckingOutbox[] = []; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, + randomBytes: deterministicRandom(), + createRoom: async (options) => { + creates += 1; + return bootstrapFromOptions(options); + }, + publish: async (options) => { + publishes += 1; + return publishBrowserSnapshots({ ...options, indexBuilder }); + }, + outboxFactory: ({ storage: outboxStorage, credentials }) => { + const outbox = new AckingOutbox(outboxStorage, credentials.roomId, outboxes.length === 0); + outboxes.push(outbox); + return outbox; + }, + }); + + 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 publication stops at simulated relay outage'); + const pending = await coordinator.inspect('https://attn.example/review'); + assert(pending, 'pending share survives failed flush'); + equal(pending.publication, 'pending', 'failed flush stays pending'); + equal(pending.invite, null, 'failed flush never materializes invite'); + const exact = JSON.stringify(outboxes[0]?.envelopes); + + const resumed = await coordinator.ensurePublished(request('workspace', [])); + equal(creates, 1, 'resume does not recreate the room'); + equal(publishes, 1, 'resume does not assemble fresh ciphertext'); + equal(JSON.stringify(outboxes[1]?.envelopes), exact, 'resume adopts exact journaled ciphertext'); + equal(resumed.roomId, pending.roomId, 'resume retains prepared room'); + equal(resumed.capId, pending.capId, 'resume retains prepared capability'); + assert(resumed.invite, 'invite appears after resumed batch ACK'); + } finally { + storage.close(); + } +}); + +test('single-active invariant and lease fences reject passive and stale mutations', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-fence'; + await seedWorkspace(storage, workspaceId); + const rootKey = await storage.getWorkspaceRootKey(workspaceId); + assert(rootKey, 'workspace root key exists'); + const active = await acquireFence(storage, workspaceId); + const identity = (await import('./browser-session')).generateBrowserIdentity(); + const capability = (await import('./browser-workspace-share')).inviteCapabilityFrom({ + roomSecret: new Uint8Array(32).fill(7), + ownerSigningSecret: identity.signingSecret, + ownerEncryptionSecret: identity.encryptionSecret, + ownerDeviceId: identity.deviceId, + ownerParticipantId: identity.participantId, + policy: { mode: 'hybrid' }, + sharePaths: ['notes/main.md'], + }); + await storage.shares.bindShareFenced(rootKey, { + workspaceId, + capId: 'cap-active', + roomId: 'room-active', + scopeKind: 'file', + relayUrl: 'https://relay.example', + capability, + }, active); + await rejectsConflict( + () => storage.shares.bindShareFenced(rootKey, { + workspaceId, + capId: 'cap-second', + roomId: 'room-second', + scopeKind: 'file', + relayUrl: 'https://relay.example', + capability, + }, active), + 'same owner cannot create a second active share', + ); + await rejectsConflict( + () => storage.shares.forgetShareFenced( + workspaceId, + 'cap-active', + { holderId: 'passive-tab', fencingToken: active.fencingToken }, + ), + 'passive tab cannot erase ownership', + ); + + const leases = storage.leases({ channel: null }); + assert(await leases.release(active), 'active lease released'); + const takeover = await leases.acquire(workspaceId, 'takeover-tab'); + leases.close(); + assert(takeover, 'new tab takes the workspace lease'); + await rejectsConflict( + () => storage.shares.forgetShareFenced(workspaceId, 'cap-active', active), + 'stale holder cannot erase ownership', + ); + assert( + await storage.shares.forgetShareFenced(workspaceId, 'cap-active', takeover), + 'current holder can erase the binding', + ); + } finally { + storage.close(); + } +}); + +test('remote stop succeeds before local erasure and recreate mints fresh ownership', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-stop-recreate'; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let deleteSucceeds = false; + let deletes = 0; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, + randomBytes: deterministicRandom(), + createRoom: async (options) => bootstrapFromOptions(options), + deleteRoom: async () => { + deletes += 1; + return deleteSucceeds; + }, + publish: async (options) => publishBrowserSnapshots({ ...options, indexBuilder }), + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingOutbox(outboxStorage, credentials.roomId, false), + }); + const first = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + + let failed = false; + try { + await coordinator.deleteRemote(); + } catch (error) { + failed = error instanceof Error && error.message.includes('existing link may still work'); + } + assert(failed, 'failed authoritative delete is surfaced honestly'); + equal((await storage.shares.listShares(workspaceId)).length, 1, 'delete failure preserves local control'); + + deleteSucceeds = true; + const deleted = await coordinator.deleteRemote(); + equal(deletes, 2, 'remote deletion retried'); + equal((await storage.shares.listShares(workspaceId)).length, 1, 'remote success alone retains local capability'); + await coordinator.eraseLocal(deleted); + equal((await storage.shares.listShares(workspaceId)).length, 0, 'local capability erased after remote success'); + + const recreated = await coordinator.ensurePublished(request('file', ['notes/main.md'])); + assert(recreated.roomId !== first.roomId, 'recreate uses a fresh room secret and room ID'); + assert(recreated.capId !== first.capId, 'recreate uses a fresh capability ID'); + } finally { + storage.close(); + } +}); + +test('expired ownership is authoritatively retired before creating a fresh room', async () => { + const storage = await openStorage(); + try { + const workspaceId = 'ws-expired-recreate'; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let now = NOW; + let deletes = 0; + const randomBytes = deterministicRandom(); + const dependencies: BrowserWorkspaceSharingDependencies = { + now: () => now, + randomBytes, + createRoom: async (options: CreateOwnedRoomOptions) => bootstrapFromOptions(options), + deleteRoom: async () => { + deletes += 1; + return true; + }, + publish: async (options: PublishBrowserSnapshotsOptions) => + publishBrowserSnapshots({ ...options, indexBuilder }), + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingOutbox(outboxStorage, credentials.roomId, false), + }; + const coordinator = new BrowserWorkspaceSharingCoordinator( + storage, + workspaceId, + fence, + dependencies, + ); + const first = await coordinator.ensurePublished({ + ...request('file', ['notes/main.md']), + ttlMs: 60 * 60 * 1000, + }); + now += 60 * 60 * 1000 + 1; + const expired = await coordinator.inspect('https://attn.example/review'); + assert(expired?.expired, 'the prior room is visibly expired'); + equal(expired.invite, null, 'an expired capability is never materialized as an invite'); + + const recreated = await coordinator.ensurePublished( + request('file', ['notes/main.md']), + ); + equal(deletes, 1, 'expired room is owner-deleted before replacement'); + assert(recreated.roomId !== first.roomId, 'replacement uses a fresh room ID'); + assert(recreated.capId !== first.capId, 'replacement uses a fresh capability ID'); + equal((await storage.shares.listShares(workspaceId)).length, 1, 'only replacement ownership remains'); + } finally { + storage.close(); + } +}); + +test('scope validation rejects stale paths, duplicates, and asset-only selections before network', async () => { + const attempts: BrowserWorkspaceShareRequest[] = [ + request('entries', ['notes/missing.md']), + request('entries', ['notes/main.md', 'notes/main.md']), + request('entries', ['assets/image.png']), + ]; + for (const [index, invalid] of attempts.entries()) { + const storage = await openStorage(); + try { + const workspaceId = `ws-invalid-scope-${index}`; + await seedWorkspace(storage, workspaceId); + const fence = await acquireFence(storage, workspaceId); + let networkCalls = 0; + const coordinator = new BrowserWorkspaceSharingCoordinator(storage, workspaceId, fence, { + now: () => NOW, + randomBytes: deterministicRandom(), + createRoom: async (options) => { + networkCalls += 1; + return bootstrapFromOptions(options); + }, + }); + let rejected = false; + try { + await coordinator.ensurePublished(invalid); + } catch (error) { + rejected = error instanceof BrowserStorageError; + } + assert(rejected, 'invalid scope rejected'); + equal(networkCalls, 0, 'invalid scope causes no relay request'); + equal((await storage.shares.listShares(workspaceId)).length, 0, 'invalid scope leaves no prepared record'); + } 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 > 0) 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..4b9a2b50 --- /dev/null +++ b/web/src/lib/review/browser-workspace-sharing.ts @@ -0,0 +1,539 @@ +// Accountless browser-owner share lifecycle (attn-7xl.4.5). +// +// The coordinator is deliberately DOM-free. It prepares ownership under the +// active workspace fence before any relay request, journals RoomCreated + the +// owner ParticipantJoined atomically with the initial encrypted snapshots, +// and only materializes invite strings for an explicitly opened Share sheet. + +import type { Capability, RoomPolicy } from '../types'; +import { + base64UrlEncode, + deriveRoomId, + deriveRoomKeys, +} from './browser-crypto'; +import { assembleBrowserEvent } from './browser-envelope'; +import { composeInviteForms, type InviteForms } from './browser-invite'; +import { + createOwnedRoom, + defaultOwnerPolicy, + deleteOwnedRoom, + type CreateOwnedRoomOptions, + type OwnedRoomBootstrap, +} from './browser-owner-bootstrap'; +import { BrowserOutbox, type BrowserOutboxPersistence } from './browser-outbox'; +import { validateBrowserRelayUrl } from './browser-relay-url'; +import { + ownerCredentialsFromInviteCapability, + generateBrowserIdentity, + type BrowserOwnerCredentials, +} from './browser-session'; +import { + publishBrowserSnapshots, + resumeBrowserSnapshotPublication, + type BrowserSnapshotEntry, + type PublishBrowserSnapshotsOptions, + type SnapshotPublicationOutbox, +} from './browser-snapshot-publisher'; +import type { BrowserStorage } from './browser-storage'; +import { BrowserStorageError, StorageConflictError } from './browser-storage-errors'; +import { + inviteCapabilityFrom, + 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'; + +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; + roomId: string; + scopeKind: ShareScopeKind; + paths: string[]; + publication: ShareRecordView['publication']; + mode: BrowserWorkspaceShareMode; + expiresAt: number; + expired: boolean; + resumable: boolean; + invite: InviteForms | null; +} + +export interface BrowserWorkspaceShareOutbox extends SnapshotPublicationOutbox { + initialize(): Promise; + close(): void; +} + +export interface BrowserWorkspaceSharingDependencies { + createRoom?: (options: CreateOwnedRoomOptions) => Promise; + deleteRoom?: typeof deleteOwnedRoom; + publish?: (options: PublishBrowserSnapshotsOptions) => Promise; + outboxFactory?: (input: { + storage: BrowserStorage; + relayUrl: string; + credentials: BrowserOwnerCredentials; + }) => BrowserWorkspaceShareOutbox; + now?: () => number; + randomBytes?: (length: number) => Uint8Array; +} + +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 active = (await this.storage.shares.listShares(this.workspaceId)) + .filter((share) => share.publication !== 'stopped') + .sort((left, right) => right.createdAt - left.createdAt); + if (active.length === 0) return null; + if (active.length !== 1) { + throw new StorageConflictError('workspace has multiple active shares'); + } + return this.view(active[0]!, browserReviewBase); + } + + /** Create, resume, or return the one active browser-owned room. */ + async ensurePublished(request: BrowserWorkspaceShareRequest): Promise { + validateRequest(request); + const relayUrl = validateBrowserRelayUrl(request.relayUrl); + let existing = await this.inspectRecord(); + if (existing) { + const existingView = await this.view(existing, request.browserReviewBase); + if (existingView.expired) { + // Expired ownership must not trap the workspace behind the single-active + // invariant. Confirm relay teardown (404/410 are idempotent success), + // then crypto-erase the sealed capability before minting a fresh room. + const retired = await this.deleteRemote(); + await this.eraseLocal(retired); + existing = null; + } else if (existing.publication === 'published') { + return existingView; + } + } + + const rootKey = await this.storage.getWorkspaceRootKey(this.workspaceId); + if (!rootKey) throw new BrowserStorageError('workspace key is unavailable'); + + let record = existing; + let capability: InviteCapability; + let credentials: BrowserOwnerCredentials; + if (record) { + capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + credentials = ownerCredentialsFromInviteCapability(capability, record.roomId); + } else { + const paths = await this.resolvePaths(request.scopeKind, request.paths); + const createdAt = this.timestamp(); + const ttlMs = request.ttlMs ?? BROWSER_SHARE_TTL_ONE_DAY; + const policy = defaultOwnerPolicy(createdAt); + policy.mode = request.mode ?? 'hybrid'; + policy.expiresAt = createdAt + ttlMs; + const roomSecret = this.exactRandom(32, 'room secret'); + const identity = generateBrowserIdentity(); + const roomId = deriveRoomId(roomSecret); + const capId = base64UrlEncode(this.exactRandom(16, 'share capability id')); + capability = inviteCapabilityFrom({ + roomSecret, + ownerSigningSecret: identity.signingSecret, + ownerEncryptionSecret: identity.encryptionSecret, + ownerDeviceId: identity.deviceId, + ownerParticipantId: identity.participantId, + policy, + sharePaths: paths, + }); + record = await this.storage.shares.bindShareFenced(rootKey, { + workspaceId: this.workspaceId, + capId, + roomId, + scopeKind: request.scopeKind, + relayUrl, + capability, + }, this.fence); + credentials = { + roomId, + roomSecret, + keys: deriveRoomKeys(roomSecret), + identity, + policy, + }; + } + + const paths = capability.sharePaths; + if (!paths || paths.length === 0) { + zeroCredentials(credentials); + throw new BrowserStorageError('prepared share is missing its exact scope paths'); + } + const outbox = this.makeOutbox(record.relayUrl, credentials); + try { + await outbox.initialize(); + 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 { + const createRoom = this.dependencies.createRoom ?? createOwnedRoom; + const created = await createRoom({ + relayUrl: record.relayUrl, + policy: credentials.policy, + longSession: credentials.policy.expiresAt - record.createdAt > BROWSER_SHARE_TTL_ONE_DAY, + identity: credentials.identity, + roomSecret: credentials.roomSecret, + now: this.now, + }); + // A prepared record is allowed to receive 201: no genesis/snapshot + // envelope is exposed until the atomic publication journal below. + if (created.roomId !== record.roomId) { + zeroBootstrapKeys(created); + throw new StorageConflictError('relay room does not match prepared ownership'); + } + zeroBootstrapKeys(created); + const sources = await this.loadSources(paths); + try { + const genesisAt = Math.max(this.timestamp(), record.createdAt); + const prefixEnvelopes = ownerGenesisEnvelopes( + credentials, + request.ownerDisplayName, + genesisAt, + ); + const publish = this.dependencies.publish ?? publishBrowserSnapshots; + await publish({ + relayUrl: record.relayUrl, + roomId: record.roomId, + roomSecret: credentials.roomSecret, + keys: credentials.keys, + identity: credentials.identity, + policy: credentials.policy, + entries: sources, + prefixEnvelopes, + 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('share publication did not promote'); + } + return await this.view(promoted, request.browserReviewBase); + } finally { + outbox.close(); + zeroCredentials(credentials); + } + } + + /** Authoritatively delete the room. Local erasure is a separate fenced step. */ + async deleteRemote(): Promise { + const record = await this.inspectRecord(); + if (!record) throw new BrowserStorageError('workspace has no active share'); + const rootKey = await this.storage.getWorkspaceRootKey(this.workspaceId); + if (!rootKey) throw new BrowserStorageError('workspace key is unavailable'); + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const credentials = ownerCredentialsFromInviteCapability(capability, record.roomId); + try { + const deleted = await (this.dependencies.deleteRoom ?? deleteOwnedRoom)({ + relayUrl: record.relayUrl, + roomId: record.roomId, + identity: credentials.identity, + admissionKey: credentials.keys.admissionKey, + }); + if (!deleted) throw new Error('The review room could not be stopped; the existing link may still work.'); + return record; + } finally { + zeroCredentials(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 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, + browserReviewBase: string, + ): Promise { + const rootKey = await this.storage.getWorkspaceRootKey(this.workspaceId); + if (!rootKey) throw new BrowserStorageError('workspace key is unavailable'); + const capability = await this.storage.shares.openShare(rootKey, this.workspaceId, record.capId); + const credentials = ownerCredentialsFromInviteCapability(capability, record.roomId); + try { + const published = record.publication === 'published'; + const expired = credentials.policy.expiresAt <= this.timestamp(); + return { + workspaceId: this.workspaceId, + capId: record.capId, + roomId: record.roomId, + scopeKind: record.scopeKind, + paths: [...(capability.sharePaths ?? capability.publishedManifest?.entries.map((entry) => entry.path) ?? [])], + publication: record.publication, + mode: credentials.policy.mode, + expiresAt: credentials.policy.expiresAt, + expired, + resumable: record.publication === 'pending', + invite: published && !expired + ? composeInviteForms(credentials.roomSecret, browserReviewBase) + : null, + }; + } finally { + zeroCredentials(credentials); + } + } + + 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'); + const unique = new Set(paths); + if (unique.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 (![...unique].some((path) => live.get(path)?.kind === 'markdown')) { + throw new BrowserStorageError('share scope must contain at least one Markdown file'); + } + return [...unique].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: BrowserOwnerCredentials, + ): 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, + powBits: credentials.policy.powBits, + maxEventBytes: credentials.policy.maxEventBytes, + maxSnapshotBytes: credentials.policy.maxSnapshotBytes, + persistence, + }); + } + + 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 identity = credentials.identity; + const common = { + eventKey: credentials.keys.eventKey, + signingSecret: identity.signingSecret, + signingPublic: identity.signingPublic, + roomId: credentials.roomId, + authorId: identity.participantId, + deviceId: identity.deviceId, + expiresAt: credentials.policy.expiresAt, + } as const; + const roomCreated = assembleBrowserEvent({ + ...common, + createdAt, + body: { + type: 'room_created', + roomId: credentials.roomId, + policy: credentials.policy, + createdBy: identity.participantId, + }, + }); + const ownerJoined = assembleBrowserEvent({ + ...common, + createdAt: createdAt + 1, + body: { + type: 'participant_joined', + participant: { + participantId: identity.participantId, + displayName: displayName?.trim() || 'Browser owner', + kind: 'owner', + publicSigningKey: base64UrlEncode(identity.signingPublic), + capabilities: [...OWNER_CAPABILITIES], + }, + device: { + deviceId: identity.deviceId, + participantId: identity.participantId, + publicEncryptionKey: base64UrlEncode(identity.publicEncryptionKey), + publicSigningKey: base64UrlEncode(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 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.identity.signingSecret.fill(0); + credentials.identity.signingPublic.fill(0); + credentials.identity.encryptionSecret.fill(0); + credentials.identity.publicEncryptionKey.fill(0); +} + +function zeroBootstrapKeys(bootstrap: OwnedRoomBootstrap): void { + bootstrap.keys.rootKey.fill(0); + bootstrap.keys.eventKey.fill(0); + bootstrap.keys.snapshotKey.fill(0); + bootstrap.keys.signalingKey.fill(0); + bootstrap.keys.admissionKey.fill(0); +} From a31148f5ac4baa7f54c9e87be446a87e827a7b03 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sat, 11 Jul 2026 08:51:59 -0600 Subject: [PATCH 2/4] feat(sharing): publish browser-owned stable shares --- src/daemon.rs | 36 +- src/main.rs | 4 +- src/review/manager.rs | 45 + src/review/share_lifecycle.rs | 215 ++++- web/e2e/hosted-share-sheet.spec.ts | 75 +- web/package.json | 1 + web/scripts/test-browser-share-owner-live.ts | 138 ++++ web/src/hosted/app/ShareSheet.svelte | 92 ++- web/src/hosted/app/app-shell.css | 57 ++ web/src/hosted/app/mock-service.ts | 27 +- web/src/hosted/app/types.ts | 11 +- web/src/lib/review/browser-invite.test.ts | 2 +- .../review/browser-owner-bootstrap.test.ts | 57 +- web/src/lib/review/browser-owner-bootstrap.ts | 182 ++++ .../browser-owner-workspace-runtime.test.ts | 81 +- .../review/browser-owner-workspace-runtime.ts | 11 +- web/src/lib/review/browser-session.ts | 131 ++- .../lib/review/browser-share-owner.test.ts | 144 ++++ web/src/lib/review/browser-share-owner.ts | 420 ++++++++++ .../lib/review/browser-snapshot-publisher.ts | 2 + web/src/lib/review/browser-snapshot-r2.ts | 8 +- web/src/lib/review/browser-workspace-share.ts | 102 +++ .../review/browser-workspace-sharing.test.ts | 776 +++++------------- .../lib/review/browser-workspace-sharing.ts | 626 +++++++++----- 24 files changed, 2311 insertions(+), 932 deletions(-) create mode 100644 web/scripts/test-browser-share-owner-live.ts create mode 100644 web/src/lib/review/browser-share-owner.test.ts create mode 100644 web/src/lib/review/browser-share-owner.ts diff --git a/src/daemon.rs b/src/daemon.rs index 9328c62a..bf37fdc1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -908,9 +908,9 @@ pub fn dispatch_review_join(invite: &str, review_manager: Option<&Arc>, @@ -927,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 @@ -1526,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..a98d7e3c 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); diff --git a/src/review/manager.rs b/src/review/manager.rs index 15c6ace3..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, diff --git a/src/review/share_lifecycle.rs b/src/review/share_lifecycle.rs index 92be5719..51b51e6c 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 { @@ -3766,7 +3901,77 @@ mod tests { &events[1].body, ReviewEventBody::CommentCreated { body, .. } if body == "valid comment after poison" )); - } + 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() { diff --git a/web/e2e/hosted-share-sheet.spec.ts b/web/e2e/hosted-share-sheet.spec.ts index 62ea029c..faf7ec44 100644 --- a/web/e2e/hosted-share-sheet.spec.ts +++ b/web/e2e/hosted-share-sheet.spec.ts @@ -21,18 +21,18 @@ async function createReadyShare(page: Page) { return dialog; } -test('defaults to the focused current-file, 24-hour hybrid flow', async ({ page }) => { +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('Access and lifetime Hybrid · 24 hours')).toBeVisible(); + await expect(dialog.getByText('Delivery mode Hybrid')).toBeVisible(); - await dialog.getByText('Access and lifetime Hybrid · 24 hours').click(); + await dialog.getByText('Delivery mode Hybrid').click(); await expect(dialog.getByRole('radio', { name: /^Hybrid/u })).toBeChecked(); - await expect(dialog.getByRole('radio', { name: '24 hours' })).toBeChecked(); + await expect(dialog).toContainText('Stable links renew for 90 days'); }); test('durability states gate sharing honestly', async ({ page }) => { @@ -53,7 +53,7 @@ test('durability states gate sharing honestly', async ({ page }) => { await expect(quotaDialog.getByRole('button', { name: 'Create review link' })).toBeDisabled(); }); -test('browser, native, and CLI forms carry one secret; copy, stop, and recreate work', async ({ page }) => { +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, @@ -65,26 +65,36 @@ test('browser, native, and CLI forms carry one secret; copy, stop, and recreate }); }); const dialog = await createReadyShare(page); - - 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()) ?? ''; - + await expect(dialog.getByRole('radio', { name: /Comment/u })).toBeChecked(); await dialog.getByText('Native app and CLI options').click(); - const inviteCodes = dialog.locator('.share-invite-option code'); - const nativeUrl = (await inviteCodes.nth(0).textContent()) ?? ''; - const cliCommand = (await inviteCodes.nth(1).textContent()) ?? ''; - const browserSecret = new URL(browserUrl).hash; - const nativeSecret = new URL(nativeUrl).hash; - expect(nativeSecret).toBe(browserSecret); - expect(cliCommand).toContain(nativeUrl); - await expect(dialog.getByRole('link', { name: 'Open in attn' })).toHaveAttribute('href', nativeUrl); - await expect(dialog.locator('[role="status"]')).toHaveCount(1); - await expect(dialog.getByRole('link', { name: 'Open in attn' })).toHaveAttribute('href', nativeUrl); - await expect(dialog.getByRole('status')).toHaveCount(1); - - await dialog.getByRole('button', { name: 'Copy browser link' }).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); @@ -113,11 +123,11 @@ test('uses Web Share when available and remains axe-clean at mobile width', asyn 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 link' }).click(); + 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\/review\/.+#key=.+/u); + expect(payload?.url).toMatch(/^https:\/\/attn\.sh\/s\/.+#key=.+/u); const overflow = await page.evaluate(() => { const root = document.scrollingElement; @@ -148,22 +158,21 @@ test('falls back to clipboard when Web Share rejects', async ({ page }) => { }); }); const dialog = await createReadyShare(page); - await dialog.getByRole('button', { name: 'Share link' }).click(); + 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\/review\/.+#key=.+/u); + 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 lifetime choice rows meet 44px touch targets', async ({ page }) => { +test('mobile permission tier rows meet 44px touch targets', async ({ page }) => { await page.setViewportSize({ width: 320, height: 700 }); - const dialog = await openShare(page); - await dialog.getByText('Access and lifetime Hybrid · 24 hours').click(); - const ttlHeights = await dialog.locator('.share-ttl-options label').evaluateAll((labels) => + 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 ttlHeights) expect(height).toBeGreaterThanOrEqual(44); + for (const height of tierHeights) expect(height).toBeGreaterThanOrEqual(44); }); test('destructive confirmation takes keyboard focus', async ({ 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/scripts/test-browser-share-owner-live.ts b/web/scripts/test-browser-share-owner-live.ts new file mode 100644 index 00000000..3413ca63 --- /dev/null +++ b/web/scripts/test-browser-share-owner-live.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env -S npx tsx +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + base64UrlEncode, + buildAdmissionHeaderV3, + deriveShareLinkKeys, +} from '../src/lib/review/browser-crypto'; +import { createOwnedRoomV3, deleteOwnedRoomV3 } from '../src/lib/review/browser-owner-bootstrap'; +import { mineBrowserPow } 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 { decryptDurableShareSnapshot } from '../src/lib/review/browser-share-production'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +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: Parameters[0]): 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}`); } +} + +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'); + + 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/ShareSheet.svelte b/web/src/hosted/app/ShareSheet.svelte index 13a2abdc..fc9b128d 100644 --- a/web/src/hosted/app/ShareSheet.svelte +++ b/web/src/hosted/app/ShareSheet.svelte @@ -2,7 +2,6 @@ import { SHARE_MODE_OPTIONS, SHARE_TTL_ONE_DAY, - SHARE_TTL_OPTIONS, createShareRequest, durabilityState, entriesForScope, @@ -24,6 +23,7 @@ } from './types'; type SheetPhase = 'loading' | 'configure' | 'progress' | 'ready' | 'stopped'; + const SHARE_TIERS = ['view', 'comment', 'suggest'] as const; interface Props { workspace?: WorkspaceDetail; @@ -92,6 +92,7 @@ let riskAcknowledged = $state(false); let revealLink = $state(false); + let selectedTier = $state<'view' | 'comment' | 'suggest'>('comment'); let inviteOptionsOpen = $state(false); let stopConfirm = $state(false); let stopBusy = $state(false); @@ -114,7 +115,8 @@ durability.allowed && scopeValid && Boolean(workspace) && Boolean(onCreate), ); const invite = $derived(share?.invite ?? null); - const maskedBrowserUrl = $derived(invite ? maskInviteUrl(invite.browserUrl) : ''); + const selectedInvite = $derived(invite?.[selectedTier] ?? null); + const maskedBrowserUrl = $derived(selectedInvite ? maskInviteUrl(selectedInvite.browserUrl) : ''); const webShareAvailable = $derived(supportsWebShare()); const durabilityCopy = $derived.by(() => { @@ -168,6 +170,7 @@ if (view.scopeKind === 'file') configuredFilePath = view.paths[0]; selectedPaths = [...view.paths]; revealLink = false; + selectedTier = 'comment'; inviteOptionsOpen = false; stopConfirm = false; @@ -325,25 +328,37 @@ } async function shareBrowserInvite(): Promise { - if (!invite) return; + if (!selectedInvite) return; if (!supportsWebShare()) { - await copyText(invite.browserUrl, 'Browser link copied.'); + await copyText(selectedInvite.browserUrl, `${tierLabel(selectedTier)} link copied.`); return; } try { await navigator.share({ title: 'Review in attn', text: `Review ${name} in attn.`, - url: invite.browserUrl, + url: selectedInvite.browserUrl, }); statusMessage = 'Review link shared.'; } catch (error) { if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') return; - const copied = await copyText(invite.browserUrl, 'Sharing was unavailable, so the browser link was copied.'); + const copied = await copyText(selectedInvite.browserUrl, 'Sharing was unavailable, so the browser link was copied.'); if (!copied) statusMessage = 'Sharing was unavailable. Reveal the link to copy it manually.'; } } + function tierLabel(tier: 'view' | 'comment' | 'suggest'): string { + return tier === 'view' ? 'View-only' : tier === 'comment' ? 'Comment' : 'Suggest'; + } + + function tierDescription(tier: 'view' | 'comment' | 'suggest'): string { + return tier === 'view' + ? 'Read the shared workspace' + : tier === 'comment' + ? 'Read and leave comments' + : 'Read, comment, and propose edits'; + } + function supportsWebShare(): boolean { if (typeof navigator === 'undefined') return false; return typeof (navigator as Navigator & { share?: unknown }).share === 'function'; @@ -498,7 +513,7 @@ @@ -575,42 +580,55 @@

{ownerStatus ?? 'Browser owner active'} · {remainingTimeLabel(share.expiresAt)}

- - + - + {#if selectedInvite} + + + + + {/if} {#if statusMessage !== 'Encrypted review link ready.'} {/if} - {/if}