Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# The anchor WASM equivalence test invokes the native Rust example.
# On Linux, the attn crate links its desktop target dependencies even
# though the example itself only exercises the canonical parser.
- name: Install native test dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
working-directory: .

- name: Setup Node
uses: actions/setup-node@v4
with:
Expand Down
46 changes: 46 additions & 0 deletions planning/web-authoring/parity-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Browser/native release parity matrix

Date: 2026-07-11 (attn-7xl.7.1)

This matrix records the repeatable evidence for the accountless browser
workspace release. `PASS` cells have an automated command. `DEVICE GATE` cells
require current physical Apple hardware and remain tracked by attn-7xl.2.9 and
attn-7xl.7.6; they are not silently treated as simulated passes.

## Owner/reviewer and transport matrix

| Owner | Reviewer | Stable/live path | Mailbox/offline path | Direct path | Result and command |
|---|---|---|---|---|---|
| Browser | Browser | Production durable-share resolver joins the browser-owned V3 RoomDO | Browser owner validates reviewer registration/grants/events, forwards exact ciphertext, ACKs ShareDO, and seals cursor | Browser transport/collab controller suites exercise direct-first delivery | **PASS** — `npm --prefix web run test:share-owner:live`, `npm --prefix web test` |
| Browser | Native | Native Rust resolves the selected stable bearer, authenticates the manifest/sealed bundle, and registers in the browser-owned room | Same room/mailbox protocol; owner-side browser drain is covered with exact frozen browser submissions | V3 room capabilities feed the existing native transport | **PASS** — `npm --prefix web run test:share-owner:live`, `cargo test --lib share_lifecycle` |
| Native | Browser | Production browser resolves retained ciphertext and upgrades into the actual native RoomDO | Comment survives destroyed RoomDO; owner restart recreates the deterministic epoch, imports/forwards/ACKs, and the existing browser upgrades without reload | Browser and native daemons negotiate a real DataChannel | **PASS** — `scripts/test-share-e2e.sh`, `scripts/test-webrtc-live-e2e.sh` |
| Native | Native | Existing canonical share/join path | Existing relay mailbox and restart paths | Real two-daemon DataChannel with comment, co-edit, and tracked suggestion | **PASS** — `scripts/test-webrtc-live-e2e.sh`, Rust review suites |
| Multiple browser tabs | Browser-owned workspace | One workspace lease/fencing token owns mutations; passive tabs are read-only until takeover | Pending ciphertext resumes byte-identically after reload/tab handoff | Per-file controller generation rejects stale deliveries | **PASS** — `browser-workspace-lease`, `browser-workspace-sharing`, `browser-owner-workspace-runtime`, and `browser-owner-authority` suites under `npm --prefix web test` |

## Content, action, and recovery matrix

| Cell | Required behavior | Result and evidence |
|---|---|---|
| Current file / selected entries / whole workspace | One canonical normalized manifest; stable FileIds; pointer publishes last | **PASS** — browser snapshot publisher/workspace manifest suites |
| Nested Markdown | Multiple files remain distinct and publish in one room; new files publish live | **PASS** — `scripts/test-folder-share-e2e.sh` (3/3) plus browser publisher tests |
| Raster and arbitrary binary assets | Raw bytes hash/length/media type are manifest-bound; binary is base64url inside E2EE snapshot; active/unknown content is not executed | **PASS** — browser snapshot publisher, session hydration, manifest, shell, and import/export suites |
| Missing/stale paths and references | Stale scope paths fail before network; deleted/renamed entries reconcile; missing assets remain inert | **PASS** — browser workspace sharing/store/manifest suites and hosted authoring Playwright |
| Mailbox and R2 thresholds | Small sealed snapshot uses mailbox; large sealed body uses authenticated same-origin R2 capability; swapped body/AAD fails | **PASS** — browser snapshot publisher/R2 suites; relay remains content-blind |
| Comments, replies, resolution | Signed events render, retry byte-identically, and owner terminal actions persist before broadcast | **PASS** — browser session, owner authority, review actions, selectors/margin suites; WebRTC live comment gate |
| Suggestions and apply | Comment tier cannot escalate; suggest tier is room-bound; accept/reject/apply uses fenced atomic receipts and survives autosave | **PASS** — browser session/authority/review-action suites; WebRTC tracked-suggestion gate |
| Co-editing | Owner is the single authority; checkpoint persists before commit/broadcast; concurrent editors converge | **PASS** — collab authority/controller/session suites; WebRTC live co-typing |
| Owner offline / reconnect | Stable reviewer can author offline; owner recreates same epoch, forwards once, ACKs after durability, and renews 90-day ShareDO | **PASS** — durable-share real stack and browser coordinator recovery tests |
| Reload and process restart | Sealed capability, pending exact ciphertext, rollback floor, outbox, and owner checkpoint resume without duplicate apply | **PASS** — browser storage/share/session/authority suites and durable-share restart E2E |
| Export/import and crypto-erasure | ZIP/backup preserves nested bytes; stop/clear removes key material before records; revoked links fail | **PASS** — import/export, storage, workspace crypto/share suites and live revoke gates |
| Invalid/malicious invite or mailbox input | Noncanonical URLs, fragments, tier mismatch, wrong grant, forged signer, ciphertext swap, rollback, and cursor gaps fail closed without secret echo | **PASS** — browser invite/share/session/push/mailbox suites and Rust resolver/lifecycle suites |

## Route, viewport, and deployment matrix

| Environment | Result |
|---|---|
| Local Chromium hosted routes/share UI | **PASS** — focused Share sheet 7/7 and full web unit suite 78 files |
| Cloudflare staging | **PASS** — worker `fb0ebc57-8e38-4b12-8f97-e1dc79f45291`; full Playwright routes/authoring/offline/share/mobile/a11y 64/64 |
| Chromium + WebKit storage/reader suite | **PASS** — prior Phase 01/02 validation records; OPFS vs honest fallback and 320–430 px reader-first layouts |
| Current iPhone Safari normal/private/Home Screen | **DEVICE GATE** — attn-7xl.2.9 / ios-device-protocol.md |
| Current iPad Safari and Split View, 200% text, VoiceOver, dynamic chrome, Web Share/native handoff | **DEVICE GATE** — attn-7xl.7.6 / ios-device-protocol.md |
| Production `attn.sh` origin and rollback rehearsal | **HUMAN APPROVAL GATE** — attn-7xl.7.4 and .7.6; production was not changed during this matrix |
41 changes: 41 additions & 0 deletions planning/web-authoring/security-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Browser workspace security review

Status: reviewed on 2026-07-11. No open critical or high-severity findings were identified in the browser-owned sharing path. Production cutover and physical iOS validation remain separate human gates.

## Scope and trust boundaries

The review covers browser workspace storage, import, rendering, service-worker behavior, browser-owned relay rooms, stable share capabilities, encrypted snapshot publication, reviewer admission, offline mailbox draining, and native/browser interoperability.

Workspace content is plaintext inside the trusted browser runtime while it is open. At rest, workspace records and share-owner state are sealed with non-extractable browser keys; large payloads may live in OPFS under the same sealed-record model. This protects persisted data and supports cryptographic erasure, but it does not protect against a compromised same-origin runtime, malicious browser extension, compromised device, or an attacker controlling an unlocked session.

Relay and hosting operators can observe traffic metadata such as opaque identifiers, timing, size, and network addresses. They do not receive workspace plaintext or invite secrets: stable invite capability material remains in the URL fragment, and published snapshots, events, and mailbox items are encrypted and authenticated in the browser.

## Controls reviewed

- Hosted responses enforce a restrictive CSP, same-origin opener/resource policies, no-referrer, HSTS, MIME sniffing protection, frame denial, and a narrow permissions policy. HTML and service-worker entry points are non-cacheable; hashed static assets are immutable.
- The service worker caches only application shells and static assets. It excludes cross-origin requests, query-bearing requests, and user workspace content. Push payloads contain no document plaintext; notification details are decrypted locally.
- Markdown parsing disables embedded HTML. Hosted HTML previews run in an iframe without sandbox capabilities and with a no-referrer policy. Mermaid uses strict security mode, and KaTeX keeps its default untrusted-input behavior.
- Relay owner requests omit credentials, disable caching, and use authenticated canonical request material. Room ownership, reviewer grants, envelopes, acknowledgements, revocation, and teardown are signed and verified using the native protocol shapes.
- Reviewer mailbox items are validated before forwarding: cursor continuity, bundle identity, device registration, owner grant, permitted event type, AEAD authentication, derived IDs, and Ed25519 signatures are checked. The source item is acknowledged only after durable forwarding.
- ZIP imports normalize and validate paths before expansion and enforce archive-size, entry-count, per-entry, and total-expanded-size limits. Traversal and expansion-budget cases have regression tests.
- Local and session storage contain presentation preferences and transient UI state only. Workspace roots, stable owner secrets, and invite capability keys are not persisted there.
- Production dependency audit for the web and relay packages reported no vulnerabilities at high severity or above. The live relay harness also scans logs for workspace plaintext and stable owner secrets.

## Findings

### Fixed: unbounded ZIP expansion

Imported ZIP metadata previously had no explicit expansion budget. The importer now rejects archives over 64 MiB, more than 1,024 entries, individual expanded files over 64 MiB, or aggregate expanded content over 128 MiB. Paths are rejected before decompression when they are absolute, traverse upward, contain NUL bytes, or otherwise normalize outside the archive root.

### Accepted limitations

- Trusted Types are not enforced because support and compatibility across the target iOS/Safari matrix are incomplete. HTML-producing sinks are instead limited to audited renderers and sandboxed preview surfaces under CSP.
- A local notification may reveal the locally decrypted document name or activity count on the device lock screen according to the user's OS notification settings.
- Possession of a stable invite URL fragment grants the advertised reviewer capability until the owner revokes or tears down the share. This is intentional bearer-capability behavior and is surfaced as such in the Share UI.
- A Rust advisory scan could not be run because `cargo-audit` is not installed in the validation environment. Cargo compilation and the protocol/interoperability test suites remain required quality gates.

## Evidence

The feature validation record is in `validation-03.md`, and the native/browser coverage map is in `parity-matrix.md`. Automated evidence includes browser unit tests, hosted-route boundary tests, staging Playwright coverage, relay integration tests, Rust share-lifecycle tests, native-to-browser and browser-to-native live relay harnesses, folder sharing, WebRTC, revocation, teardown, and ciphertext/log leakage checks.

The remaining gates do not change this review result: physical iOS Safari verification requires real devices, and production routing requires explicit human approval.
18 changes: 18 additions & 0 deletions planning/web-authoring/validation-03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Phase 03 validation record — browser-owned encrypted sharing

Date: 2026-07-11 (attn-7xl.4.7)

Reproducible from the repository root unless noted.

| Gate (03-browser-sharing.md §Validation) | Command | Result |
|---|---|---|
| Svelte/TypeScript and production bundles | `cd web && npm run check && npm run build:browser && npm run check:route-bundles` | 0 errors, 0 warnings; browser + service-worker builds pass; landing/app statically reach only 8/12 files and do not preload editor or crypto chunks |
| Browser protocol/storage/authority suites | `cd web && npm test` | 78 test files, 0 failures — includes native-compatible v3 bootstrap, canonical snapshot/manifest publication with Markdown and inert binary assets, fenced owner authority, stable ShareDO ownership, renewal/recreate, offline mailbox preflight/forward/ACK, rollback, stop/recreate, and tier isolation |
| Rust stable-link resolver and owner lifecycle | `cargo check && cargo test --lib share_lifecycle` | native resolver authenticates the selected ShareDO record, manifest, sealed tier bundle, room pointer, and v3 capability fragment; 14/14 lifecycle tests pass |
| Browser owner → browser/native reviewers against real relay | `cd web && npm run test:share-owner:live` | real Wrangler RoomDO + ShareDO: browser creates/owns v3 room, uploads retained ciphertext, flips stable pointer, authenticates mailbox query/ACK, the production browser reviewer and native Rust both resolve the browser comment bearer and join the same room, then owner revoke/teardown succeeds; relay log scan contains neither snapshot plaintext nor stable owner secret |
| Native owner ↔ production browser, offline mailbox, restart, watch upgrade, revoke | `scripts/test-share-e2e.sh` | full real-stack lifecycle passes: browser decrypts retained native snapshot and joins live; offline browser comment survives missing RoomDO; restarted native owner recreates the deterministic epoch, imports/forwards/ACKs, browser upgrades without reload, and revoke terminates the session |
| Multi-file publication | `scripts/test-folder-share-e2e.sh` | 3/3: two nested Markdown files publish in one room, non-Markdown input is excluded by this legacy native gate, and a newly-created Markdown file publishes live. Browser publisher unit coverage separately proves canonical workspace manifests and inert binary assets without UTF-8 coercion |
| Live direct transport and owner/reviewer editing | `scripts/test-webrtc-live-e2e.sh` | 12/12: authenticated daemon join, mutual presence, both badges `live_direct`, comment delivery over DataChannel, convergent co-typing, reviewer edit arrives as a tracked suggestion, accepted-only autosave, and suggestion survives reseed risk |
| Share-sheet UX, mobile, and accessibility | `cd web && npx playwright test e2e/hosted-share-sheet.spec.ts --config playwright.routes.config.ts` | 7/7: independent view/comment/suggest bearers, matching browser/native/CLI tier forms, Web Share + clipboard fallback, axe clean, ≥44 px mobile permission targets, and keyboard-focused destructive confirmation |
| Cloudflare staging | `cd web && npm run deploy:staging` then `ATTN_ROUTES_BASE_URL=https://staging.attn.sh npx playwright test --config playwright.routes.config.ts` | deployed worker version `fb0ebc57-8e38-4b12-8f97-e1dc79f45291`; staging asset/relay-origin verification passes; full routes/authoring/offline/share/mobile/a11y matrix 64/64 |
| Production `attn.sh` cutover and real iPhone/iPad matrix | — | **Not performed** — production remains explicitly human-gated in attn-7xl.7; current-device Safari validation remains tracked with attn-7xl.2.9 |
2 changes: 1 addition & 1 deletion relay/test/integration/share-lifecycle-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,5 +425,5 @@ describe("durable share v3 real-stack lifecycle", () => {
expect((await SELF.fetch(`${shareUrl}/snapshots/readme`)).status).toBe(404);
expect((await SELF.fetch(mailboxUrl)).status).toBe(404);
expect((await env.RELAY_BLOBS.list({ prefix: shareArtifactPrefix(shareId) })).objects).toHaveLength(0);
});
}, 30_000);
});
4 changes: 3 additions & 1 deletion scripts/test-folder-share-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ if [ -n "${ATTN_EXTERNAL_RELAY:-}" ]; then
else
[ -d "$PROJECT_DIR/relay/node_modules" ] || (cd relay && npm ci >/dev/null)
log "Starting relay on :$RELAY_PORT"
( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$!
( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" \
--var QUOTA_ALLOW_UNATTRIBUTED_CREATES:true \
--var BLOB_CAP_SIGNING_KEY:folder-share-e2e-blob-key-material-32 ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$!
deadline=$(( $(date +%s) + 60 )); while [ "$(date +%s)" -lt "$deadline" ]; do curl -fsS "$RELAY_URL/health" >/dev/null 2>&1 && break; kill -0 "$RELAY_PID" 2>/dev/null || { log "relay died"; exit 1; }; sleep 0.3; done
fi
log "Relay healthy"
Expand Down
22 changes: 17 additions & 5 deletions scripts/test-webrtc-live-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ if [ -n "${ATTN_EXTERNAL_RELAY:-}" ]; then
else
[ -d "$PROJECT_DIR/relay/node_modules" ] || (cd relay && npm ci >/dev/null)
log "Starting relay on :$RELAY_PORT"
( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" ) >"$RELAY_LOG" 2>&1 &
( cd "$PROJECT_DIR/relay" && exec npx wrangler dev --local --port "$RELAY_PORT" \
--var QUOTA_ALLOW_UNATTRIBUTED_CREATES:true \
--var BLOB_CAP_SIGNING_KEY:webrtc-live-e2e-blob-key-material-32 ) >"$RELAY_LOG" 2>&1 &
RELAY_PID=$!
deadline=$(( $(date +%s) + 60 ))
while [ "$(date +%s)" -lt "$deadline" ]; do curl -fsS "$RELAY_URL/health" >/dev/null 2>&1 && break; kill -0 "$RELAY_PID" 2>/dev/null || { log "relay died"; tail -20 "$RELAY_LOG"; exit 1; }; sleep 0.3; done
Expand All @@ -80,15 +82,25 @@ wait_ready attn_owner '[data-slot=share-invite-url]' 20000 || { log "no invite f
INVITE=""; deadline=$(( $(date +%s) + 15 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
INVITE="$(attn_owner --eval "document.querySelector('[data-slot=share-invite-url]')?.value||''" 2>/dev/null | tr -d '"\\' | tr -d '\r\n')"
case "$INVITE" in attn://review/*) break;; esac; sleep 0.3
case "$INVITE" in attn://review/*|https://attn.sh/review/*|https://staging.attn.sh/review/*) break;; esac; sleep 0.3
done
case "$INVITE" in attn://review/*) ok "owner minted invite";; *) bad "no invite ('$INVITE')"; exit 1;; esac
case "$INVITE" in attn://review/*|https://attn.sh/review/*|https://staging.attn.sh/review/*) ok "owner minted invite";; *) bad "no invite ('$INVITE')"; exit 1;; esac

log "Reviewer joins (review_join IPC)"
log "Reviewer joins through the authenticated daemon socket"
# Pre-set the reviewer's name too so the post-join onboarding prompt doesn't
# overlay the editor during the co-typing/suggesting assertions below.
attn_rv --eval "window.__attn_user_profile__ && window.__attn_user_profile__.save('Reviewer');'x'" >/dev/null 2>&1 || true
attn_rv --eval "window.ipc && window.ipc.postMessage(JSON.stringify({type:'review_join',invite:'$INVITE'}));'x'" >/dev/null 2>&1 && ok "reviewer join dispatched" || bad "reviewer join failed"
JOIN_INVITE="$INVITE"
case "$JOIN_INVITE" in
https://attn.sh/review/*) JOIN_INVITE="attn://review/${JOIN_INVITE#https://attn.sh/review/}" ;;
https://staging.attn.sh/review/*) JOIN_INVITE="attn://review/${JOIN_INVITE#https://staging.attn.sh/review/}" ;;
esac
JOIN_ERROR=""
if JOIN_ERROR="$(attn_rv review join "$JOIN_INVITE" 2>&1)"; then
ok "reviewer join dispatched"
else
bad "reviewer join failed: ${JOIN_ERROR%%$'\n'*}"
fi

# Presence converges (each sees 1 peer).
peer_count() { "$1" --query '[data-slot=peer-chip]' 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin).get("count",0))' 2>/dev/null || echo 0; }
Expand Down
Loading
Loading