diff --git a/docs/adversarial_rubric.md b/.agents/skills/release/references/adversarial_rubric.md similarity index 100% rename from docs/adversarial_rubric.md rename to .agents/skills/release/references/adversarial_rubric.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fbcadc938..7ece47f91 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: - 'apps/marketing/**' - 'apps/portal/**' - 'apps/paste-service/**' + - 'apps/room-service/**' - 'packages/**' workflow_dispatch: inputs: @@ -21,6 +22,7 @@ on: - marketing - portal - paste + - room permissions: contents: read @@ -32,6 +34,7 @@ jobs: marketing: ${{ steps.changes.outputs.marketing }} portal: ${{ steps.changes.outputs.portal }} paste: ${{ steps.changes.outputs.paste }} + room: ${{ steps.changes.outputs.room }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,6 +57,11 @@ jobs: else echo "paste=false" >> $GITHUB_OUTPUT fi + if [[ "${{ inputs.target }}" == "all" || "${{ inputs.target }}" == "room" ]]; then + echo "room=true" >> $GITHUB_OUTPUT + else + echo "room=false" >> $GITHUB_OUTPUT + fi else # For push events, check what changed git fetch origin ${{ github.event.before }} --depth=1 2>/dev/null || true @@ -61,6 +69,7 @@ jobs: MARKETING_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/marketing/|packages/)' || true) PORTAL_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/portal/|packages/)' || true) PASTE_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^apps/paste-service/' || true) + ROOM_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/room-service/|packages/shared/collab/|packages/editor/|packages/ui/)' || true) if [[ -n "$MARKETING_CHANGED" ]]; then echo "marketing=true" >> $GITHUB_OUTPUT @@ -79,6 +88,12 @@ jobs: else echo "paste=false" >> $GITHUB_OUTPUT fi + + if [[ -n "$ROOM_CHANGED" ]]; then + echo "room=true" >> $GITHUB_OUTPUT + else + echo "room=false" >> $GITHUB_OUTPUT + fi fi deploy-marketing: @@ -178,3 +193,28 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + deploy-room: + needs: detect-changes + if: needs.detect-changes.outputs.room == 'true' + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build browser shell + run: bun run --cwd apps/room-service build:shell + + - name: Deploy to Cloudflare + working-directory: apps/room-service + run: npx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4e60f62d..eb976ae76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,9 @@ jobs: run: bun run typecheck - name: Run tests - run: bun test + # See .github/workflows/test.yml for why this is `bun run test` + # and not raw `bun test`. + run: bun run test build: needs: test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d319f9401..985aa4f13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,12 @@ jobs: run: bun run typecheck - name: Run tests - run: bun test + # Use the root `test` script (splits non-UI + UI-cwd) so the + # packages/ui/bunfig.toml happy-dom preload is loaded. Raw + # `bun test` from the repo root doesn't pick up that package- + # scoped preload, so UI hook tests would hit "document is not + # defined". + run: bun run test install-cmd-windows: # End-to-end integration test for scripts/install.cmd on real cmd.exe. diff --git a/.gitignore b/.gitignore index 9d3f75a12..56a531b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ plannotator-local # Local research/reference docs (not for repo) /reference/ *.bun-build + +.wrangler/ +apps/room-service/public/ +.claude/scheduled_tasks.lock +specs/ diff --git a/AGENTS.md b/AGENTS.md index d2249f246..fbf081cad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,16 @@ plannotator/ │ │ ├── index.html │ │ ├── index.tsx │ │ └── vite.config.ts +│ ├── room-service/ # Live collaboration rooms (Cloudflare Worker + Durable Object) +│ │ ├── core/ # Handler, DO class, validation, CORS, log, types, csp +│ │ ├── targets/cloudflare.ts # Worker entry + DO re-export +│ │ ├── entry.tsx # Browser shell entry — path switch: / → LandingPage, /c/:roomId → AppRoot +│ │ ├── index.html # Vite template; produces hashed chunks under /assets/ +│ │ ├── vite.config.ts # Browser shell build (bun run build:shell) +│ │ ├── tsconfig.browser.json # DOM-lib tsconfig for the shell +│ │ ├── static/ # Root-level static assets copied into public/ by build:shell (favicon.svg) +│ │ ├── scripts/smoke.ts # Integration test against wrangler dev +│ │ └── wrangler.toml # SQLite-backed DO binding + ASSETS binding (run_worker_first, html_handling=none) │ ├── vscode-extension/ # VS Code extension — opens plans in editor tabs │ │ ├── bin/ # Router scripts (open-in-vscode, xdg-open) │ │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts @@ -58,7 +68,8 @@ plannotator/ │ │ ├── components/ # Viewer, Toolbar, Settings, etc. │ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views -│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser +│ │ │ ├── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser +│ │ │ └── collab/ # RoomStatusBadge, ParticipantAvatars, RoomHeaderControls, RoomMenu, RoomUnavailableScreen, JoinRoomGate, StartRoomModal, RemoteCursorLayer, ImageStripNotice, LandingPage, LandingPreview │ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below) │ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator │ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks @@ -66,16 +77,28 @@ plannotator/ │ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer) │ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog) │ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator) -│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts +│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts, adminSecretStorage.ts, blockTargeting.ts │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts +│ │ │ └── collab/ # useCollabRoom.ts, useCollabRoomSession.ts, useLandingCreateRoom.ts, usePresenceThrottle.ts, useRoomMode.ts, useRoomAdminActions.ts, useStartLiveRoom.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) │ ├── shared/ # Shared types, utilities, and cross-runtime logic │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) -│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) -│ ├── editor/ # Plan review app -│ │ ├── App.tsx # Main plan review app +│ │ ├── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ └── collab/ # Live Rooms protocol, crypto, validators, client runtime, React hook +│ │ ├── types.ts # Protocol types + runtime validators +│ │ ├── crypto.ts # HKDF key derivation, HMAC proofs, AES-GCM payload encrypt/decrypt +│ │ ├── ids.ts # roomId/secret/opId/clientId generators +│ │ ├── url.ts # parseRoomUrl / buildRoomJoinUrl / buildAdminRoomUrl (client-only) +│ │ ├── constants.ts # ROOM_SECRET_LENGTH_BYTES, ADMIN_SECRET_LENGTH_BYTES, WS_CLOSE_* +│ │ ├── strip-images.ts # toRoomAnnotation, stripRoomAnnotationImages +│ │ ├── redact-url.ts # redactRoomSecrets (scrub #key=/#admin= from telemetry/logs) +│ │ └── client-runtime/ # CollabRoomClient class, createRoom, joinRoom, apply-event reducer +│ ├── editor/ # Plan review app (App.tsx) + room-mode shell +│ │ ├── App.tsx # Plan review editor (local + room-mode prop) +│ │ ├── AppRoot.tsx # Mode fork (local | room | invalid-room); package default export +│ │ └── RoomApp.tsx # Room-mode shell — identity gate, session, overlays, delete/expired fallbacks │ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries │ └── review-editor/ # Code review UI │ ├── App.tsx # Main review app @@ -308,6 +331,21 @@ All servers use random ports locally or fixed port (`19432`) in remote mode. Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted). +### Room Service (`apps/room-service/`) + +Live-collaboration rooms for encrypted multi-user annotation. Zero-knowledge: the Worker + Durable Object stores and relays ciphertext only. Clients hold the room secret in the URL fragment and derive `authKey`/`eventKey`/`presenceKey`/`adminKey` locally. + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/` | GET | Landing page for room creation from uploaded document. Serves the same `index.html` shell; `entry.tsx` path switch renders `LandingPage` (lazy-loaded). | +| `/health` | GET | Worker liveness probe | +| `/c/:roomId` | GET | Room SPA shell — serves the built editor bundle. Response carries CSP, `Cache-Control: no-store`, `Referrer-Policy: no-referrer`. | +| `/api/rooms` | POST | Create room. Body: `{ roomId, roomVerifier, adminVerifier, initialSnapshotCiphertext, expiresInDays? }`. Returns `201` on success; `409` on duplicate. | +| `/api/fetch-markdown` | POST | URL-to-markdown proxy. Body: `{ url }`. Returns `{ markdown, source }`. | +| `/ws/:roomId` | GET | WebSocket upgrade into the room Durable Object. | + +Protocol contract lives in `packages/shared/collab/`; the Worker/DO never imports client-only URL helpers. + ## Plan Version History Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version). diff --git a/apps/collab-agent/AGENT_INSTRUCTIONS.md b/apps/collab-agent/AGENT_INSTRUCTIONS.md new file mode 100644 index 000000000..44ffa85ba --- /dev/null +++ b/apps/collab-agent/AGENT_INSTRUCTIONS.md @@ -0,0 +1,189 @@ +# Plannotator Live Rooms — Agent Instructions + +This document is prose an AI agent (Claude Code, Codex, OpenCode, +Junie, or another) should have in its prompt when it's being +driven to participate in a Plannotator Live Room. It explains +the identity convention, the CLI subcommand surface, and the +handful of rules that keep agent participation well-behaved. + +## 1. Identity + +Your identity in the room follows the pattern: + +``` +-agent- +``` + +Examples: `swift-falcon-tater-agent-claude`, +`alice-agent-codex`. + +- `` is the human you're acting on behalf of. If you've + been given their Plannotator identity (a "tater name" like + `swift-falcon-tater`), use it verbatim. +- `` is one of: `claude`, `codex`, `opencode`, `junie`, + `other`. Use `other` when you don't fit any of the explicit + kinds — it's a legal value, not a fallback error. + +You pass these as `--user` and `--type` on every CLI invocation; +the CLI assembles the full identity string and refuses to run if +either is missing or malformed. + +Room participants see your identity in their avatar row and as +the label on your cursor. A small `⚙` marker appears next to the +identity on both surfaces so observers can tell you're an agent, +not a human teammate. + +## 2. Joining and staying visible + +The V1 room protocol has no participant roster. Peers appear on +one another's screens **only after presence is received**. A +client that just connects and stays silent is invisible. + +Two subcommands handle this correctly: + +- `join` — connect, emit initial presence, heartbeat presence on + a 10s cadence, stream room events to stdout until Ctrl-C. Use + this when you need to be present while you think or wait. +- `demo` — a showcase walk; not for real work. + +Short one-shot reads (`read-plan`, `read-annotations`, +`read-presence`) emit presence exactly once before they print and +exit. You briefly flash into the observer's avatar row, then +disappear. + +Do **not** implement your own WebSocket or presence loop. The +CLI is the supported entry point. + +## 3. Reading the plan + +``` +bun run apps/collab-agent/index.ts read-plan \ + --url "" \ + --user --type +``` + +Add `--with-block-ids` to get each block prefixed with +`[block:]`. You need those ids if you plan to comment. + +Block ids are **derived from the markdown** — the CLI uses the +same parser the browser uses, so the ids you read here are +byte-identical to what the observer sees in their DOM. + +## 4. Reading existing annotations + +``` +bun run apps/collab-agent/index.ts read-annotations \ + --url "..." --user --type +``` + +Prints the full `RoomAnnotation[]` array as pretty JSON. Fields: +`id`, `blockId`, `startOffset`, `endOffset`, `type`, `text`, +`originalText`, `createdA`, `author`. + +## 5. Reading recent presence + +``` +bun run apps/collab-agent/index.ts read-presence \ + --url "..." --user --type +``` + +Prints `remotePresence` as JSON keyed by opaque per-connection +client ids. **This is NOT a participant roster.** It is +"peers who've emitted presence in the last 30 seconds." A user +who's connected but idle (not moving their mouse) will NOT +appear. Do not infer "who's in the room" from this call. + +## 6. Posting a comment + +Block-level only in V1. + +``` +bun run apps/collab-agent/index.ts comment \ + --url "..." --user --type \ + --block --text "" +``` + +The annotation targets the entire block — its full content is the +"original text", and your `--text` becomes the comment body. Do +**not** attempt to select a sub-range of text. The V1 agent flow +does not support inline text-range targeting; the +`/api/external-annotations` inline-text matcher that some agents +may have used before is known to fail silently on markdown / +whitespace / NBSP / block-boundary drift. + +### Choosing a block id + +Three ways: + +1. Run `read-plan --with-block-ids` to see the plan interleaved + with block markers. +2. Run `read-annotations` to see block ids on annotations other + agents or humans have already left. +3. Run `comment --list-blocks` (with `--url/--user/--type`) to + print a JSON array of `{ id, type, content }` for every block + and exit without posting. + +Pick a block whose `content` matches what you want to comment on. + +### Referencing specific wording + +If your comment is about specific wording within a block, quote +the wording **in the comment body**, not as an anchor: + +``` +--text 'The phrase "as soon as possible" is ambiguous — what is the deadline?' +``` + +Do not try to select only `"as soon as possible"`. Select the +whole block, and put the phrase in prose. + +### Exit codes + +- `0` — comment echoed back from the server (confirmed posted). +- `1` — snapshot / echo timeout, unknown block id, or server + rejected the op (e.g. the room was deleted). +- `2` — argv or usage error (missing flag, bad --type, etc.). + +## 7. Demo mode + +``` +bun run apps/collab-agent/index.ts demo \ + --url "..." --user --type \ + --duration 120 +``` + +Walks heading blocks in order, anchors the cursor to each, posts +a comment per heading. For showcase only — not a real +participation pattern. Pass `--dry-run` to do the cursor walk +without posting. + +## 8. Rules and limits + +- **Never run as admin.** The CLI strips any `#admin=` + fragment from the URL by default and warns on stderr. There is + no opt-in flag. Agents do not perform delete. +- **No image attachments.** V1 room annotations do not carry + images. If you need to share an image, the flow is via the + local editor's import path, not via the agent CLI. +- **Room annotations are server-authoritative.** Your + `sendAnnotationAdd` queues a local op; the server has the + final say. The `comment` subcommand waits for the echo before + exiting 0. +- **Text appears to peers after server echo.** Your comment + doesn't appear in your own `read-annotations` output until it + round-trips. + +## 9. Troubleshooting + +- **`Missing --url` / `Missing --user` / `Missing --type`** — + argv check. Add the missing flag. +- **`Timed out waiting for snapshot after 10000ms`** — the URL + parsed but the connection never received the initial + encrypted snapshot. Check the URL fragment is intact + (`#key=`) and the room service is reachable. +- **`unknown --block ""`** — the block id you passed isn't + in the current plan. Run `comment --list-blocks` to see the + valid set; re-run with a matching id. +- **`: `** on a comment — server-side mutation + rejection. The message names the reason; wait and retry or + target a different room. diff --git a/apps/collab-agent/README.md b/apps/collab-agent/README.md new file mode 100644 index 000000000..eee551bae --- /dev/null +++ b/apps/collab-agent/README.md @@ -0,0 +1,140 @@ +# @plannotator/collab-agent + +Command-line tool that lets an AI agent join a Plannotator Live +Room as a first-class peer — read the plan, read annotations, +post comments, emit presence. + +This is a human-readable README. Agent-facing prompt text lives +in [`AGENT_INSTRUCTIONS.md`](./AGENT_INSTRUCTIONS.md). + +## Install + +Everything is already wired as a workspace package. From the +repo root: + +```sh +bun install +``` + +## Quick tour + +The root `package.json` has a convenience script, but you can +also call the entry file directly. + +```sh +# Help +bun run agent:run --help + +# Read the plan (add --with-block-ids for block markers) +bun run agent:run read-plan \ + --url "http://localhost:8787/c/#key=..." \ + --user alice --type claude + +# Stay connected and stream events (Ctrl-C to exit) +bun run agent:run join \ + --url "..." --user alice --type claude + +# Post a block-level comment +bun run agent:run comment \ + --url "..." --user alice --type claude \ + --block \ + --text "Looks good, but consider section 3." + +# List blocks without posting +bun run agent:run comment \ + --url "..." --user alice --type claude --list-blocks +``` + +## Identity + +Agent identities follow the pattern `-agent-`. + +- `--user` must match `/^[a-z0-9][a-z0-9-]*$/` — lowercase alnum + with dashes. Case is normalized. +- `--type` is one of: `claude`, `codex`, `opencode`, `junie`, + `other`. + +The CLI assembles the full identity string. Peers see it as your +display name and in their avatar row. A small `⚙` marker makes +agent participants visually distinct from humans. + +## Subcommands + +| Subcommand | What it does | +|---|---| +| `join` | Connect, emit initial presence, heartbeat at 10s, stream room events to stdout until SIGINT. | +| `read-plan` | Print the decrypted plan markdown. `--with-block-ids` prefixes each block with `[block:]`. | +| `read-annotations` | Print the current `RoomAnnotation[]` array as JSON. | +| `read-presence` | Print `remotePresence` (recent emitters, not a roster). `--settle ` extends the wait (default 2s). | +| `comment` | Post a block-level COMMENT annotation. Requires `--block` + `--text`. `--list-blocks` prints available blocks and exits without posting. | +| `demo` | Walk heading blocks in order, anchor the cursor to each, leave a comment. `--duration `, `--comment-template `, `--dry-run`. | + +## Common flags + +Every subcommand takes: + +| Flag | Meaning | +|---|---| +| `--url ` | Full room URL including the `#key=` fragment. | +| `--user ` | Lowercase alnum + dashes. Forms the first half of the identity. | +| `--type ` | `claude \| codex \| opencode \| junie \| other`. | + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success. | +| 1 | Runtime error — connect / snapshot / echo timeout, server rejection, unknown block id. | +| 2 | Argv or usage error — missing required flag, bad `--type`. | + +## Admin URLs are stripped automatically + +If the URL you pass contains `#admin=` (e.g. you copied +the creator's admin link instead of the participant link), the +CLI strips that fragment before connecting and prints a warning +to stderr. Agents never run as room admins in V1. There is no +opt-in flag. + +## Running against a local dev room + +To test end-to-end locally with both halves (a browser as +creator, an agent as participant): + +```sh +# Terminal 1 — boot the full local stack (wrangler + editor) +bun run dev:live-room + +# In the editor tab, click "Start Live Room" to get a URL. +# Copy the participant URL (not the admin URL — either works, +# but the CLI will strip admin for you). + +# Terminal 2 — join as an agent +bun run agent:run join \ + --url "http://localhost:8787/c/#key=..." \ + --user test --type claude +``` + +Observer watches the browser tab; the agent should appear in +the avatar row with the `⚙` marker and persist there for as +long as the `join` subcommand is running. + +## Internals + +The CLI is a thin layer over `CollabRoomClient` in +`packages/shared/collab/client-runtime/client.ts`. It reuses: + +- `joinRoom()` factory (connect + key derivation + auth + handshake). +- `parseMarkdownToBlocks()` (same markdown → block id derivation + as the browser, so `--block` ids match what the observer + renders). +- `PRESENCE_SWATCHES` / `hashNameToSwatch()` (identity ←→ color + mapping; each agent identity maps deterministically to a + distinct swatch). +- `isAgentIdentity()` + the agent-identity helpers + (`packages/ui/utils/agentIdentity.ts` — a new pure module + without ConfigStore / React deps, importable by both the CLI + and the room UI components that render the `⚙` marker). + +No new protocol; no server changes. Agents are first-class peers +in the existing V1 room protocol. diff --git a/apps/collab-agent/heartbeat.ts b/apps/collab-agent/heartbeat.ts new file mode 100644 index 000000000..edf7b6820 --- /dev/null +++ b/apps/collab-agent/heartbeat.ts @@ -0,0 +1,80 @@ +/** + * Heartbeat presence manager for the agent CLI. + * + * The room protocol has no roster / join broadcast; peers appear in + * avatar rows + cursor layers only when presence is received. + * The client-runtime sweep removes presence entries older than + * `PRESENCE_TTL_MS` (30s) from the receiver's view of a peer. + * + * An agent that goes quiet (post a comment, wait for a reply) would + * therefore vanish from observers after ~30s. Human users refresh + * presence through mousemove; an agent has no such ambient signal. + * The heartbeat solves this by re-sending the last-known presence + * on a 10s cadence (~3× headroom under the TTL) whenever the CLI + * holds a live connection. + * + * Usage: + * + * const heartbeat = startHeartbeat(client, presence); + * // ... do agent work, periodically call heartbeat.update(nextPresence) + * heartbeat.stop(); + * + * The manager swallows send errors (presence is lossy by design; + * reconnects handle cross-session state rebuild). It silently no-ops + * when the client is not in the `authenticated` state so tear-down + * windows don't spam the socket. + * + * Interval coupling: `HEARTBEAT_INTERVAL_MS` must stay well below + * `PRESENCE_TTL_MS` in the client runtime (currently 30s). If that + * constant ever tightens, this interval needs to tighten too. + */ + +import type { CollabRoomClient } from '@plannotator/shared/collab/client'; +import type { PresenceState } from '@plannotator/shared/collab'; + +export const HEARTBEAT_INTERVAL_MS = 10_000; + +export interface HeartbeatHandle { + /** Replace the presence payload that will be re-sent on each tick. */ + update(next: PresenceState): void; + /** Stop the heartbeat. Safe to call multiple times. */ + stop(): void; +} + +/** + * Start a heartbeat that re-sends the given presence every + * `HEARTBEAT_INTERVAL_MS`. Does NOT send an initial presence — + * callers are expected to `await client.sendPresence(initial)` + * once themselves before starting the heartbeat so peers see the + * agent appear immediately, not only after the first heartbeat tick. + */ +export function startHeartbeat( + client: CollabRoomClient, + initialPresence: PresenceState, +): HeartbeatHandle { + let current = initialPresence; + let stopped = false; + + const timer = setInterval(() => { + if (stopped) return; + // Only tick when authenticated. The client's sendPresence will + // no-op on non-authenticated sockets, but checking here avoids + // console noise during reconnect windows. + const state = client.getState(); + if (state.connectionStatus !== 'authenticated') return; + void client.sendPresence(current).catch(() => { + // Presence is lossy by protocol contract; drop failures. + }); + }, HEARTBEAT_INTERVAL_MS); + + return { + update(next: PresenceState) { + current = next; + }, + stop() { + if (stopped) return; + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/apps/collab-agent/identity.test.ts b/apps/collab-agent/identity.test.ts new file mode 100644 index 000000000..065c66b22 --- /dev/null +++ b/apps/collab-agent/identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import { isAgentType, stripAdminFragment } from './identity'; + +describe('isAgentType', () => { + test('accepts known types', () => { + expect(isAgentType('claude')).toBe(true); + expect(isAgentType('codex')).toBe(true); + expect(isAgentType('opencode')).toBe(true); + expect(isAgentType('junie')).toBe(true); + expect(isAgentType('other')).toBe(true); + }); + + test('rejects unknown types', () => { + expect(isAgentType('gpt')).toBe(false); + expect(isAgentType('')).toBe(false); + expect(isAgentType('CLAUDE')).toBe(false); // case-sensitive + }); +}); + +describe('stripAdminFragment', () => { + test('removes admin param, preserves key', () => { + const url = 'https://room.example.com/c/abc123#key=secret&admin=adminsecret'; + const result = stripAdminFragment(url); + expect(result.stripped).toBe(true); + expect(result.url).toBe('https://room.example.com/c/abc123#key=secret'); + }); + + test('removes admin when it is the only fragment param (no trailing #)', () => { + const url = 'https://room.example.com/c/abc123#admin=adminsecret'; + const result = stripAdminFragment(url); + expect(result.stripped).toBe(true); + expect(result.url).toBe('https://room.example.com/c/abc123'); + }); + + test('passes through URLs without any fragment', () => { + const url = 'https://room.example.com/c/abc123'; + const result = stripAdminFragment(url); + expect(result.stripped).toBe(false); + expect(result.url).toBe(url); + }); + + test('passes through URLs with fragment but no admin', () => { + const url = 'https://room.example.com/c/abc123#key=secret&stripped=2'; + const result = stripAdminFragment(url); + expect(result.stripped).toBe(false); + expect(result.url).toBe(url); + }); + + test('preserves non-admin fragment params in order', () => { + const url = 'https://room.example.com/c/abc#key=k&admin=a&name=alice&color=%23ff0000'; + const result = stripAdminFragment(url); + expect(result.stripped).toBe(true); + // URLSearchParams stringify preserves insertion order minus the deleted key. + expect(result.url).toBe('https://room.example.com/c/abc#key=k&name=alice&color=%23ff0000'); + }); +}); diff --git a/apps/collab-agent/identity.ts b/apps/collab-agent/identity.ts new file mode 100644 index 000000000..33819ad1b --- /dev/null +++ b/apps/collab-agent/identity.ts @@ -0,0 +1,70 @@ +/** + * Agent identity + URL sanitisation helpers for the CLI. The pure + * construction/detection helpers live in + * `@plannotator/ui/utils/agentIdentity` — this file layers on + * CLI-specific concerns: + * + * - parsing `--user` / `--type` argv into a validated agent + * identity string; + * - stripping `#admin=` out of a room URL so an agent + * never accidentally runs with admin capability even if the + * user pastes a creator-side admin link. + * + * The admin-URL guard is a hard default in V1. There is no + * `--as-admin` opt-in; agents are never admins. Adding that + * surface area without a concrete use case is footgun creation + * (per the plan's risk note). + */ + +import { + constructAgentIdentity, + InvalidAgentIdentityError, + type AgentType, + AGENT_TYPES, +} from '@plannotator/ui/utils/agentIdentity'; + +export { constructAgentIdentity, InvalidAgentIdentityError, AGENT_TYPES }; +export type { AgentType }; + +/** True when the supplied string is a recognised agent type. */ +export function isAgentType(value: string): value is AgentType { + return (AGENT_TYPES as readonly string[]).includes(value); +} + +/** + * Result of `stripAdminFragment`. `stripped` indicates whether an + * `admin=…` param was actually present and removed — callers use + * this to print the CLI warning exactly once per run. + */ +export interface StripAdminFragmentResult { + url: string; + stripped: boolean; +} + +/** + * Remove `admin=` from a room URL's fragment while + * preserving `key=…` and anything else. Returns the input + * unchanged when no fragment or no admin param is present. + * + * Implementation note: room URL fragments are parsed as + * `URLSearchParams` strings by the client (see `parseRoomUrl` + * in `packages/shared/collab/url.ts`), so this function follows + * the same shape — split on `#`, treat the right half as a + * URLSearchParams, delete `admin`, rebuild. + */ +export function stripAdminFragment(rawUrl: string): StripAdminFragmentResult { + const hashIdx = rawUrl.indexOf('#'); + if (hashIdx < 0) return { url: rawUrl, stripped: false }; + + const base = rawUrl.slice(0, hashIdx); + const fragment = rawUrl.slice(hashIdx + 1); + const params = new URLSearchParams(fragment); + if (!params.has('admin')) return { url: rawUrl, stripped: false }; + + params.delete('admin'); + const rebuilt = params.toString(); + return { + url: rebuilt ? `${base}#${rebuilt}` : base, + stripped: true, + }; +} diff --git a/apps/collab-agent/index.ts b/apps/collab-agent/index.ts new file mode 100644 index 000000000..6b885ee6a --- /dev/null +++ b/apps/collab-agent/index.ts @@ -0,0 +1,106 @@ +/** + * @plannotator/collab-agent — CLI entry point. + * + * Dispatches to a subcommand under `./subcommands/`. Each subcommand + * parses its own argv (via the shared helpers in `_lib.ts`), + * manages its own connection lifecycle, and returns an exit code. + * + * Usage: + * bun run apps/collab-agent/index.ts --url --user --type [...] + * + * Subcommands: + * join connect and stay online with heartbeat presence + * read-plan print decrypted plan markdown (add --with-block-ids for block markers) + * read-annotations print current annotations as JSON + * read-presence print recent peer presence (not a roster) + * comment post a block-level comment annotation + * demo walk headings and leave comments at each + * + * Exit codes: + * 0 success + * 1 runtime error (connect timeout, server rejection, ...) + * 2 argument / usage error + */ + +import { runJoin } from './subcommands/join'; +import { runReadPlan } from './subcommands/read-plan'; +import { runReadAnnotations } from './subcommands/read-annotations'; +import { runReadPresence } from './subcommands/read-presence'; +import { runComment } from './subcommands/comment'; +import { runDemo } from './subcommands/demo'; +import { UsageError } from './subcommands/_lib'; + +const HELP = `plannotator collab-agent — join Live Rooms as an AI agent + +Usage: + bun run apps/collab-agent/index.ts [options] + +Subcommands: + join connect and stay online with heartbeat presence + read-plan print decrypted plan markdown + (add --with-block-ids for block markers) + read-annotations print current annotations as JSON + read-presence print recent peer presence (not a participant roster) + comment post a block-level comment annotation + (--block --text , or --list-blocks + to print available block ids + exit) + demo walk heading blocks in order, anchor the cursor + to each, and post a comment per heading + (--duration , --comment-template , + --dry-run to skip posting) + +Common flags (every subcommand): + --url full room URL including #key=... fragment + --user lowercase alnum + dashes; becomes -agent- + --type claude | codex | opencode | junie | other + +Examples: + bun run apps/collab-agent/index.ts read-plan \\ + --url "http://localhost:8787/c/abc123#key=..." \\ + --user alice --type claude + + bun run apps/collab-agent/index.ts join \\ + --url "https://room.plannotator.ai/c/xyz#key=..." \\ + --user swift-falcon-tater --type codex +`; + +type Subcommand = (argv: readonly string[]) => Promise; + +const SUBCOMMANDS: Record = { + join: runJoin, + 'read-plan': runReadPlan, + 'read-annotations': runReadAnnotations, + 'read-presence': runReadPresence, + comment: runComment, + demo: runDemo, +}; + +async function main(argv: readonly string[]): Promise { + const sub = argv[0]; + if (!sub || sub === '--help' || sub === '-h') { + console.log(HELP); + return 0; + } + + const runner = SUBCOMMANDS[sub]; + if (!runner) { + console.error(`collab-agent: unknown subcommand "${sub}"`); + console.error('Run with --help for the subcommand list.'); + return 2; + } + + try { + return await runner(argv.slice(1)); + } catch (err) { + if (err instanceof UsageError) { + console.error(`collab-agent: ${err.message}`); + console.error('Run with --help for usage.'); + return 2; + } + console.error(`collab-agent: ${(err as Error).message ?? String(err)}`); + return 1; + } +} + +const code = await main(process.argv.slice(2)); +process.exit(code); diff --git a/apps/collab-agent/package.json b/apps/collab-agent/package.json new file mode 100644 index 000000000..3e84a8603 --- /dev/null +++ b/apps/collab-agent/package.json @@ -0,0 +1,17 @@ +{ + "name": "@plannotator/collab-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run index.ts" + }, + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*" + }, + "devDependencies": { + "bun-types": "^1.3.11", + "typescript": "~5.8.2" + } +} diff --git a/apps/collab-agent/subcommands/_lib.test.ts b/apps/collab-agent/subcommands/_lib.test.ts new file mode 100644 index 000000000..8ea02c48a --- /dev/null +++ b/apps/collab-agent/subcommands/_lib.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { parseCommonArgs, UsageError } from './_lib'; + +describe('parseCommonArgs', () => { + // Regression: earlier the parser used the literal string 'true' as its + // boolean-flag sentinel, so a user flag value that happened to be the + // 4-character string 'true' (e.g. `--text true` as a comment body) got + // silently dropped during re-emit. Downstream `readStringFlag` then + // threw "--text requires a value" even though one was supplied. Pinning + // this so a future refactor can't quietly bring the collision back. + test('preserves the literal string "true" as a flag value', () => { + const result = parseCommonArgs([ + '--url', 'https://example.com#key=abc', + '--user', 'alice', + '--type', 'claude', + '--text', 'true', + ]); + expect(result.rest).toEqual(['--text', 'true']); + }); + + test('treats --flag with no following value as a boolean (no re-emit value)', () => { + const result = parseCommonArgs([ + '--url', 'https://example.com#key=abc', + '--user', 'alice', + '--type', 'claude', + '--dry-run', + ]); + expect(result.rest).toEqual(['--dry-run']); + }); + + test('rejects missing required --url', () => { + expect(() => parseCommonArgs(['--user', 'alice', '--type', 'claude'])) + .toThrow(UsageError); + }); +}); diff --git a/apps/collab-agent/subcommands/_lib.ts b/apps/collab-agent/subcommands/_lib.ts new file mode 100644 index 000000000..f4694e515 --- /dev/null +++ b/apps/collab-agent/subcommands/_lib.ts @@ -0,0 +1,276 @@ +/** + * Shared helpers for the agent subcommands: argv parsing of the + * common `--url`/`--user`/`--type` shape, connect + identity + * construction, and a one-shot cleanup-on-signal wiring. + * + * Each subcommand file owns its own top-level flow; this lib just + * dedupes the boilerplate that would otherwise repeat four times. + */ + +import { + joinRoom, + type CollabRoomClient, +} from '@plannotator/shared/collab/client'; +import type { PresenceState } from '@plannotator/shared/collab'; +import { hashNameToSwatch } from '@plannotator/ui/utils/presenceColor'; +import { + constructAgentIdentity, + isAgentType, + stripAdminFragment, + AGENT_TYPES, + type AgentType, +} from '../identity'; + +export interface CommonArgs { + url: string; + user: string; + type: AgentType; + /** Raw argv slice AFTER the subcommand name, for subcommand-specific flags. */ + rest: string[]; +} + +/** + * Parse `--url`, `--user`, `--type` plus anything else. Throws a + * `UsageError` (caught by the dispatcher) on any missing or + * malformed required flag so error messages land in one place. + */ +export class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'UsageError'; + } +} + +// Sentinel for boolean-style flags (no value token followed). Using a Symbol +// instead of the literal string 'true' avoids a collision where a user passes +// the literal word "true" as a flag value (e.g. `--text true`) — the old code +// dropped that value during re-emit because it couldn't distinguish the +// sentinel from a real argv token. +const BOOL_FLAG = Symbol('boolFlag'); +type FlagValue = string | typeof BOOL_FLAG; + +export function parseCommonArgs(argv: readonly string[]): CommonArgs { + const flags = new Map(); + const rest: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token.startsWith('--')) { + const key = token.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + flags.set(key, BOOL_FLAG); + } else { + flags.set(key, next); + i++; + } + } else { + rest.push(token); + } + } + + const url = flags.get('url'); + const user = flags.get('user'); + const type = flags.get('type'); + + if (typeof url !== 'string') throw new UsageError('Missing --url'); + if (typeof user !== 'string') throw new UsageError('Missing --user'); + if (typeof type !== 'string') throw new UsageError(`Missing --type (one of ${AGENT_TYPES.join('|')})`); + if (!isAgentType(type)) { + throw new UsageError(`--type must be one of ${AGENT_TYPES.join('|')}; got "${type}"`); + } + + // Consume the common flags from the flags-turned-rest reconstruction so + // subcommand-specific args can be read from `rest` as a plain + // --flag value stream. Simpler: re-emit the non-common flags. + const consumed = new Set(['url', 'user', 'type']); + const passthrough: string[] = []; + for (const [k, v] of flags) { + if (consumed.has(k)) continue; + passthrough.push(`--${k}`); + if (v !== BOOL_FLAG) passthrough.push(v); + } + + return { url, user, type, rest: [...passthrough, ...rest] }; +} + +/** + * Read a string flag from an already-parsed `rest` stream. Returns + * undefined when absent. Throws UsageError when the flag is present + * but has no value (i.e. immediately followed by another `--flag`). + */ +export function readStringFlag(rest: readonly string[], name: string): string | undefined { + const idx = rest.indexOf(`--${name}`); + if (idx < 0) return undefined; + const next = rest[idx + 1]; + if (next === undefined || next.startsWith('--')) { + throw new UsageError(`--${name} requires a value`); + } + return next; +} + +export function readBoolFlag(rest: readonly string[], name: string): boolean { + return rest.includes(`--${name}`); +} + +export function readNumberFlag(rest: readonly string[], name: string): number | undefined { + const raw = readStringFlag(rest, name); + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isFinite(n)) { + throw new UsageError(`--${name} must be a number; got "${raw}"`); + } + return n; +} + +export interface AgentSession { + client: CollabRoomClient; + identity: string; + color: string; + /** Ready-to-send initial presence (null cursor). */ + initialPresence: PresenceState; +} + +/** + * Strip `#admin=` (warning to stderr), construct the agent identity, + * derive the identity-based color, connect via `joinRoom`, and return + * a session bag. Does NOT emit initial presence — callers choose + * whether to emit once (one-shot subcommands) or emit + heartbeat + * (`join` / `demo`). + */ +export async function openAgentSession(args: CommonArgs): Promise { + const { url: rawUrl, stripped } = stripAdminFragment(args.url); + if (stripped) { + console.warn( + '[collab-agent] URL contained #admin=; stripped. ' + + 'Agents do not run in admin mode in V1.', + ); + } + + const identity = constructAgentIdentity({ user: args.user, type: args.type }); + const color = hashNameToSwatch(identity); + + const client = await joinRoom({ + url: rawUrl, + user: { id: identity, name: identity, color }, + autoConnect: true, + }); + + const initialPresence: PresenceState = { + user: { id: identity, name: identity, color }, + cursor: null, + }; + + return { client, identity, color, initialPresence }; +} + +/** + * Wait for a `snapshot` event (full initial snapshot delivered by + * the server after auth). After this resolves, `client.getState()` + * has planMarkdown + annotations populated. Times out at + * `timeoutMs` (default 10s) so a malformed room doesn't hang + * read-* subcommands forever. + */ +export function awaitInitialSnapshot( + client: CollabRoomClient, + timeoutMs = 10_000, +): Promise { + return new Promise((resolve, reject) => { + const state = client.getState(); + // Snapshot may already be present if joinRoom completed the + // handshake before we subscribed (race window is small but real). + if (state.planMarkdown.length > 0 || state.annotations.length > 0) { + resolve(); + return; + } + const timer = setTimeout(() => { + off(); + reject(new Error(`Timed out waiting for snapshot after ${timeoutMs}ms`)); + }, timeoutMs); + const off = client.on('snapshot', () => { + clearTimeout(timer); + off(); + resolve(); + }); + }); +} + +/** + * Resolve when `annotationId` appears in canonical state (server + * echoed the op back), reject when a mutation-scoped error arrives + * after the call site or on timeout. Use this to gate subcommand + * success on "the server accepted the op", not merely "we sent the + * bytes" (which is all `sendAnnotationAdd` resolves on — see the + * `Resolves when queued/sent to the server` comment in + * `packages/shared/collab/client-runtime/client.ts:493`). + * + * IMPORTANT: subscribe BEFORE calling `sendAnnotationAdd`. The + * state event for our echo can land faster than a macrotask, so + * a late subscriber will miss it. Canonical usage: + * + * const echo = awaitAnnotationEcho(client, id); // subscribe first + * await client.sendAnnotationAdd([annotation]); + * await echo; + * + * @param timeoutMs defaults to 10s; matches the admin-command + * timeout the server honours so we wait at + * least as long as any valid server response + * could take. + */ +export function awaitAnnotationEcho( + client: CollabRoomClient, + annotationId: string, + timeoutMs = 10_000, +): Promise { + return new Promise((resolve, reject) => { + const baselineErrorId = client.getState().lastErrorId; + const timer = setTimeout(() => { + off(); + reject(new Error(`Timed out waiting for echo of ${annotationId} after ${timeoutMs}ms`)); + }, timeoutMs); + const off = client.on('state', state => { + if (state.annotations.some(a => a.id === annotationId)) { + clearTimeout(timer); + off(); + resolve(); + return; + } + // Only mutation-scoped errors apply here; admin / event / + // presence / snapshot / join errors are unrelated to our + // pending op. A fresh mutation error (id advanced past the + // baseline) is the rejection signal from the server. + if ( + state.lastErrorId > baselineErrorId && + state.lastError?.scope === 'mutation' + ) { + clearTimeout(timer); + off(); + reject(new Error(`${state.lastError.code}: ${state.lastError.message}`)); + } + }); + }); +} + +/** + * Wire SIGINT + SIGTERM to a graceful `client.disconnect()`. Returns + * a function that removes the handlers — call it after disconnect + * completes in the non-signal path so we don't accumulate listeners + * across subcommand invocations in the same process. + */ +export function wireSignalShutdown(client: CollabRoomClient): () => void { + const onSignal = () => { + try { + client.disconnect('user_interrupt'); + } catch { + // disconnect is idempotent; swallow double-call errors + } + // Give the socket a beat to send a close frame before we exit. + setTimeout(() => process.exit(0), 100); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + return () => { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + }; +} diff --git a/apps/collab-agent/subcommands/comment.ts b/apps/collab-agent/subcommands/comment.ts new file mode 100644 index 000000000..6eeba02b3 --- /dev/null +++ b/apps/collab-agent/subcommands/comment.ts @@ -0,0 +1,129 @@ +/** + * `comment` subcommand — post a block-level COMMENT annotation. + * + * Arg shape: + * --block target block (from `read-plan --with-block-ids`) + * --text comment body + * --list-blocks print the block id → content map as JSON and + * exit without posting (convenience for agents + * that want to pick a block without a separate + * `read-plan` call) + * + * Block-level targeting by design: the annotation spans the entire + * block, so the "selection accuracy" issue that plagues + * `/api/external-annotations` inline-text matching doesn't apply. + * V1 agents do NOT attempt sub-range targeting. + * + * Exit codes: + * 0 comment echoed back from server + * 1 timeout, server rejection, or missing block + * 2 argv / usage error (propagated from the dispatcher) + */ + +import type { RoomAnnotation } from '@plannotator/shared/collab'; +import { parseMarkdownToBlocks } from '@plannotator/ui/utils/parser'; +import { + awaitAnnotationEcho, + awaitInitialSnapshot, + openAgentSession, + parseCommonArgs, + readBoolFlag, + readStringFlag, + UsageError, +} from './_lib'; + +const ECHO_TIMEOUT_MS = 10_000; + +export async function runComment(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + const listOnly = readBoolFlag(args.rest, 'list-blocks'); + const blockId = readStringFlag(args.rest, 'block'); + const text = readStringFlag(args.rest, 'text'); + + if (!listOnly) { + if (!blockId) throw new UsageError('comment: --block is required'); + if (!text) throw new UsageError('comment: --text is required'); + } + + const session = await openAgentSession(args); + const { client, identity } = session; + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + return 1; + } + + const snapshot = client.getState(); + const blocks = parseMarkdownToBlocks(snapshot.planMarkdown); + + if (listOnly) { + const map = blocks.map(b => ({ id: b.id, type: b.type, content: b.content })); + process.stdout.write(JSON.stringify(map, null, 2)); + process.stdout.write('\n'); + client.disconnect('list_done'); + await new Promise(r => setTimeout(r, 100)); + return 0; + } + + // blockId + text are non-null here (enforced above); narrow for TS. + if (!blockId || !text) { + // Defensive — should never fire because we validated above. + client.disconnect('internal_error'); + return 1; + } + + const block = blocks.find(b => b.id === blockId); + if (!block) { + console.error( + `[collab-agent] unknown --block "${blockId}". Run with --list-blocks to see available ids.`, + ); + client.disconnect('unknown_block'); + return 1; + } + + await client.sendPresence(session.initialPresence); + + // V1 room annotation ids are opaque strings; the `ann-agent-` + // prefix just makes agent-posted rows identifiable in logs / + // exports without affecting server behavior. + const annotationId = `ann-agent-${crypto.randomUUID()}`; + const annotation: RoomAnnotation = { + id: annotationId, + blockId: block.id, + // Block-level target: the whole block is the original text. + startOffset: 0, + endOffset: block.content.length, + type: 'COMMENT', + text, + originalText: block.content, + createdA: Date.now(), + author: identity, + }; + + // Subscribe BEFORE sending — shared helper awaits echo in + // canonical state, rejecting on mutation-scope errors or timeout. + const echo = awaitAnnotationEcho(client, annotationId, ECHO_TIMEOUT_MS); + await client.sendAnnotationAdd([annotation]); + + try { + await echo; + } catch (err) { + console.error(`[collab-agent] comment rejected: ${(err as Error).message}`); + client.disconnect('mutation_failed'); + return 1; + } + + // Success — print the echoed annotation so invoking code can + // parse the id and attribution. + const finalState = client.getState(); + const echoed = finalState.annotations.find(a => a.id === annotationId); + process.stdout.write(JSON.stringify(echoed ?? annotation, null, 2)); + process.stdout.write('\n'); + + client.disconnect('comment_done'); + await new Promise(r => setTimeout(r, 100)); + return 0; +} diff --git a/apps/collab-agent/subcommands/demo.ts b/apps/collab-agent/subcommands/demo.ts new file mode 100644 index 000000000..db8bb9250 --- /dev/null +++ b/apps/collab-agent/subcommands/demo.ts @@ -0,0 +1,230 @@ +/** + * `demo` subcommand — walk the plan's heading blocks in order, + * anchor the agent's cursor to each heading, pause a human-feeling + * few seconds, and post a block-level comment at each stop. + * + * Intended for showcasing "an agent is participating in this room" + * to an observer watching the browser tab. Not a production agent + * behavior — real work goes through `comment` with explicit args. + * + * Cursor coordinates use `coordinateSpace: 'block'` with the target + * heading's block id so observers' `RemoteCursorLayer` anchors the + * cursor to the rendered block rect — robust to viewport size and + * consistent across peers. + * + * Args (in addition to the common --url / --user / --type): + * --duration total wall time; pauses are scaled so + * the demo fits (default 120) + * --comment-template comment body per heading; `{heading}` + * is replaced with the heading's text + * content, `{level}` with the heading + * level number (default: + * "[demo] reviewing {heading}") + * --dry-run move the cursor + heartbeat presence + * but DO NOT post comments + */ + +import type { PresenceState, RoomAnnotation } from '@plannotator/shared/collab'; +import { parseMarkdownToBlocks } from '@plannotator/ui/utils/parser'; +import { startHeartbeat } from '../heartbeat'; +import { + awaitAnnotationEcho, + awaitInitialSnapshot, + openAgentSession, + parseCommonArgs, + readBoolFlag, + readNumberFlag, + readStringFlag, + UsageError, + wireSignalShutdown, +} from './_lib'; + +const DEFAULT_DURATION_SEC = 120; +const DEFAULT_COMMENT_TEMPLATE = '[demo] reviewing {heading}'; +const MIN_PAUSE_MS = 3_000; +const MAX_PAUSE_MS = 6_000; +// Per-heading echo wait. Shorter than the 10s default in the +// comment subcommand because demo is time-boxed; if the server is +// healthy an echo arrives in <100ms, and a full 10s wait on every +// heading would dominate the demo's wall time when something is +// genuinely wrong (e.g. the room was deleted). +const DEMO_ECHO_TIMEOUT_MS = 5_000; + +export async function runDemo(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + const durationSec = readNumberFlag(args.rest, 'duration') ?? DEFAULT_DURATION_SEC; + const template = readStringFlag(args.rest, 'comment-template') ?? DEFAULT_COMMENT_TEMPLATE; + const dryRun = readBoolFlag(args.rest, 'dry-run'); + + if (durationSec <= 0) { + throw new UsageError(`--duration must be positive; got ${durationSec}`); + } + + const session = await openAgentSession(args); + const { client, identity, color } = session; + const unwireSignals = wireSignalShutdown(client); + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + unwireSignals(); + return 1; + } + + const snapshot = client.getState(); + const blocks = parseMarkdownToBlocks(snapshot.planMarkdown); + const headings = blocks.filter(b => b.type === 'heading'); + + if (headings.length === 0) { + console.error( + '[collab-agent] demo: no heading blocks in this plan; nothing to walk', + ); + client.disconnect('no_headings'); + unwireSignals(); + return 1; + } + + // Distribute the duration across headings. Clamp to a sensible + // range so a very long duration with one heading doesn't camp + // forever on a single block, and a very short duration with many + // headings doesn't turn into a flash-card rotation. + const perHeadingMs = Math.max( + MIN_PAUSE_MS, + Math.min(MAX_PAUSE_MS, Math.floor((durationSec * 1000) / headings.length)), + ); + + await client.sendPresence(session.initialPresence); + const heartbeat = startHeartbeat(client, session.initialPresence); + + console.log( + JSON.stringify({ + event: 'demo.start', + identity, + headings: headings.length, + perHeadingMs, + dryRun, + }), + ); + + interface CommentFailure { + blockId: string; + reason: string; + } + const failures: CommentFailure[] = []; + + try { + for (const heading of headings) { + // Anchor cursor to the heading block. Observer's + // RemoteCursorLayer resolves block-space cursors against its + // own rendered block rect, so the agent's cursor label lands + // on the heading regardless of the observer's viewport size. + // + // x/y are randomized per visit so that multiple agents in + // the same room don't stack their cursor labels at the same + // pixel when they both anchor to a heading. Range 20–200 px + // horizontally covers most block widths without often + // spilling past the right edge (and RemoteCursorLayer clamps + // or shows an edge indicator if it does). 0–24 px vertically + // keeps the cursor near the heading text baseline without + // wandering into the next block. + const presence: PresenceState = { + user: { id: identity, name: identity, color }, + cursor: { + coordinateSpace: 'block', + blockId: heading.id, + x: Math.floor(20 + Math.random() * 180), + y: Math.floor(Math.random() * 24), + }, + }; + heartbeat.update(presence); + await client.sendPresence(presence); + + console.log( + JSON.stringify({ + event: 'demo.visit', + blockId: heading.id, + level: heading.level ?? 0, + content: heading.content, + }), + ); + + // Natural pause before posting. Observer has time to notice + // the cursor move, then the comment appears at the end of + // the pause window plus the echo round-trip (typically tens + // of ms on a healthy server). + await new Promise(r => setTimeout(r, perHeadingMs)); + + if (!dryRun) { + const annotationId = `ann-agent-${crypto.randomUUID()}`; + const body = template + .replace('{heading}', heading.content) + .replace('{level}', String(heading.level ?? 0)); + const annotation: RoomAnnotation = { + id: annotationId, + blockId: heading.id, + startOffset: 0, + endOffset: heading.content.length, + type: 'COMMENT', + text: body, + originalText: heading.content, + createdA: Date.now(), + author: identity, + }; + + // Subscribe before sending; await echo. Confirming per + // heading means demo's exit code reflects whether every + // comment actually posted, not just "we sent the bytes". + // A deleted room, disconnect, or server-side rejection + // arrives as a rejection here — we record the failure, + // log it, and keep walking so the observer still sees + // the tour complete. Final exit code reflects whether + // ANY comment failed. + const echo = awaitAnnotationEcho(client, annotationId, DEMO_ECHO_TIMEOUT_MS); + try { + await client.sendAnnotationAdd([annotation]); + await echo; + console.log( + JSON.stringify({ event: 'demo.comment', blockId: heading.id, annotationId }), + ); + } catch (err) { + const reason = (err as Error).message; + failures.push({ blockId: heading.id, reason }); + console.error( + JSON.stringify({ event: 'demo.comment.failed', blockId: heading.id, reason }), + ); + } + } + } + } catch (err) { + console.error(`[collab-agent] demo error: ${(err as Error).message}`); + heartbeat.stop(); + client.disconnect('demo_error'); + unwireSignals(); + return 1; + } + + // Gentle grace period so the final comment has time to echo + // before we tear the socket down. The heartbeat keeps the agent + // visible during this window. + await new Promise(r => setTimeout(r, 1_500)); + + heartbeat.stop(); + client.disconnect('demo_done'); + unwireSignals(); + await new Promise(r => setTimeout(r, 100)); + + console.log( + JSON.stringify({ + event: 'demo.end', + headings: headings.length, + failed: failures.length, + failures, + }), + ); + // Non-zero exit when any comment failed to echo, so an invoking + // script can distinguish "cursor walk visible but no comments + // landed" from a clean run. + return failures.length > 0 ? 1 : 0; +} diff --git a/apps/collab-agent/subcommands/join.ts b/apps/collab-agent/subcommands/join.ts new file mode 100644 index 000000000..334adc817 --- /dev/null +++ b/apps/collab-agent/subcommands/join.ts @@ -0,0 +1,104 @@ +/** + * `join` subcommand — connect to the room, emit an initial presence + * payload, start a 10 s heartbeat, and stream interesting events to + * stdout until the process receives SIGINT. + * + * Heartbeat is what keeps the agent visible on observers while it's + * idle. Without it, the V1 protocol has no participant roster; the + * observer's 30 s presence TTL would sweep us away. + */ + +import { startHeartbeat } from '../heartbeat'; +import { + awaitInitialSnapshot, + openAgentSession, + parseCommonArgs, + wireSignalShutdown, + type CommonArgs, +} from './_lib'; + +export async function runJoin(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + return runJoinWithArgs(args); +} + +async function runJoinWithArgs(args: CommonArgs): Promise { + const session = await openAgentSession(args); + const { client, identity } = session; + + const unwireSignals = wireSignalShutdown(client); + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + unwireSignals(); + return 1; + } + + // Announce ourselves visually. `sendPresence` is lossy but the + // initial emit is worth surfacing if it fails — that signals a + // protocol or key-derivation issue the user should know about. + await client.sendPresence(session.initialPresence); + + const heartbeat = startHeartbeat(client, session.initialPresence); + + const state = client.getState(); + console.log( + JSON.stringify({ + event: 'joined', + identity, + roomId: state.roomId, + clientId: state.clientId, + planBytes: state.planMarkdown.length, + annotationCount: state.annotations.length, + }), + ); + + // Stream events to stdout so an invoking agent can react. Keep it + // light — only events a consumer plausibly cares about. Each line + // is a complete JSON object (NDJSON), easy to parse line-by-line. + client.on('event', (serverEvent) => { + console.log(JSON.stringify({ event: 'room.event', data: serverEvent })); + }); + client.on('presence', (entry) => { + // Suppress our own echoed presence (never broadcast by server, + // but belt-and-braces against future protocol changes). + if (entry.clientId === client.getState().clientId) return; + console.log( + JSON.stringify({ + event: 'room.presence', + clientId: entry.clientId, + user: entry.presence.user, + cursor: entry.presence.cursor, + }), + ); + }); + // Watch the `state` event for roomUnavailable — a single terminal + // flag replaces the old 'deleted' / 'expired' status values. Fires + // once when the server closes us with "Room unavailable" (admin + // delete, auto-expiry, or an unknown-room socket). + let alreadyUnavailable = false; + client.on('state', (state) => { + if (!alreadyUnavailable && state.roomUnavailable) { + alreadyUnavailable = true; + console.log(JSON.stringify({ event: 'room.unavailable' })); + heartbeat.stop(); + client.disconnect('room_unavailable'); + unwireSignals(); + process.exit(0); + } + }); + client.on('error', (err) => { + console.error(JSON.stringify({ event: 'room.error', ...err })); + }); + + // Keep the event loop alive. The socket + heartbeat timer already + // hold refs, but an extra long-lived timer is cheap belt-and-braces + // against runtimes that would otherwise exit early. + setInterval(() => {}, 1 << 30); + + // Never resolves under normal operation — signal handlers exit. + return await new Promise(() => {}); +} diff --git a/apps/collab-agent/subcommands/read-annotations.ts b/apps/collab-agent/subcommands/read-annotations.ts new file mode 100644 index 000000000..c756d9319 --- /dev/null +++ b/apps/collab-agent/subcommands/read-annotations.ts @@ -0,0 +1,33 @@ +/** + * `read-annotations` subcommand — connect, print the current + * annotations list as JSON, disconnect. Each annotation is printed + * as the raw RoomAnnotation shape from the protocol; consumers map + * fields themselves. + */ + +import { awaitInitialSnapshot, openAgentSession, parseCommonArgs } from './_lib'; + +export async function runReadAnnotations(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + + const session = await openAgentSession(args); + const { client } = session; + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + return 1; + } + + await client.sendPresence(session.initialPresence); + + const state = client.getState(); + process.stdout.write(JSON.stringify(state.annotations, null, 2)); + process.stdout.write('\n'); + + client.disconnect('read_done'); + await new Promise((r) => setTimeout(r, 100)); + return 0; +} diff --git a/apps/collab-agent/subcommands/read-plan.ts b/apps/collab-agent/subcommands/read-plan.ts new file mode 100644 index 000000000..e5d90c791 --- /dev/null +++ b/apps/collab-agent/subcommands/read-plan.ts @@ -0,0 +1,56 @@ +/** + * `read-plan` subcommand — connect, briefly flash our presence so + * observers see us, print the decrypted plan markdown, disconnect. + * + * With `--with-block-ids`, prefix each block with `[block:]\n` + * so agents that need to target comments can pair the source + * markdown with the block ids the browser derives from it. The + * block parsing is shared with the browser renderer (identical + * `parseMarkdownToBlocks` call) so ids round-trip. + */ + +import { parseMarkdownToBlocks } from '@plannotator/ui/utils/parser'; +import { + awaitInitialSnapshot, + openAgentSession, + parseCommonArgs, + readBoolFlag, +} from './_lib'; + +export async function runReadPlan(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + const withBlockIds = readBoolFlag(args.rest, 'with-block-ids'); + + const session = await openAgentSession(args); + const { client } = session; + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + return 1; + } + + // Emit presence once so an observer sees the agent flash during + // the read. We don't heartbeat — the subcommand exits shortly. + await client.sendPresence(session.initialPresence); + + const state = client.getState(); + if (!withBlockIds) { + process.stdout.write(state.planMarkdown); + if (!state.planMarkdown.endsWith('\n')) process.stdout.write('\n'); + } else { + const blocks = parseMarkdownToBlocks(state.planMarkdown); + for (const block of blocks) { + process.stdout.write(`[block:${block.id}] `); + process.stdout.write(block.content); + process.stdout.write('\n'); + } + } + + client.disconnect('read_done'); + // Give the socket a beat to send close frame. + await new Promise((r) => setTimeout(r, 100)); + return 0; +} diff --git a/apps/collab-agent/subcommands/read-presence.ts b/apps/collab-agent/subcommands/read-presence.ts new file mode 100644 index 000000000..f9881dff5 --- /dev/null +++ b/apps/collab-agent/subcommands/read-presence.ts @@ -0,0 +1,51 @@ +/** + * `read-presence` subcommand — connect, emit our presence once, + * wait 2 s for peers to emit, print the remote presence snapshot, + * disconnect. + * + * Output includes a banner clarifying that this is *recent + * presence*, not a participant roster. The V1 protocol has no + * roster broadcast; users who are connected but haven't emitted + * presence within the TTL will not appear. Agents trusting the + * output as a full roster would get wrong answers. + */ + +import { awaitInitialSnapshot, openAgentSession, parseCommonArgs, readNumberFlag } from './_lib'; + +const DEFAULT_SETTLE_MS = 2_000; + +export async function runReadPresence(argv: readonly string[]): Promise { + const args = parseCommonArgs(argv); + const settleSec = readNumberFlag(args.rest, 'settle'); + const settleMs = settleSec !== undefined ? Math.max(0, settleSec) * 1000 : DEFAULT_SETTLE_MS; + + const session = await openAgentSession(args); + const { client } = session; + + try { + await awaitInitialSnapshot(client); + } catch (err) { + console.error(`[collab-agent] ${(err as Error).message}`); + client.disconnect('snapshot_timeout'); + return 1; + } + + await client.sendPresence(session.initialPresence); + + // Let inbound presence settle. Observers emit on mouse move, so + // in an idle room we expect zero inbound — that's the honest + // answer, not a bug. + await new Promise((r) => setTimeout(r, settleMs)); + + const state = client.getState(); + process.stderr.write( + '[collab-agent] note: this is RECENT PRESENCE, not a participant roster. ' + + 'Connected-but-idle peers (no cursor move in the last 30s) will NOT appear.\n', + ); + process.stdout.write(JSON.stringify(state.remotePresence, null, 2)); + process.stdout.write('\n'); + + client.disconnect('read_done'); + await new Promise((r) => setTimeout(r, 100)); + return 0; +} diff --git a/apps/collab-agent/tsconfig.json b/apps/collab-agent/tsconfig.json new file mode 100644 index 000000000..2b6519a8c --- /dev/null +++ b/apps/collab-agent/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "types": ["bun-types"] + }, + "exclude": ["**/*.test.ts"] +} diff --git a/apps/hook/public/favicon.svg b/apps/hook/public/favicon.svg new file mode 100644 index 000000000..070e83e2e --- /dev/null +++ b/apps/hook/public/favicon.svg @@ -0,0 +1,5 @@ + + + + P + diff --git a/apps/hook/tsconfig.json b/apps/hook/tsconfig.json index 93ef3e28b..1b3c3a05f 100644 --- a/apps/hook/tsconfig.json +++ b/apps/hook/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "@/*": ["./*"], "@plannotator/ui/*": ["../../packages/ui/*"], - "@plannotator/editor": ["../../packages/editor/App.tsx"], + "@plannotator/editor": ["../../packages/editor/AppRoot.tsx"], + "@plannotator/editor/App": ["../../packages/editor/App.tsx"], "@plannotator/editor/*": ["../../packages/editor/*"] }, "allowImportingTsExtensions": true, diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts index f9bcbb2ec..2ffc5c3b9 100644 --- a/apps/hook/vite.config.ts +++ b/apps/hook/vite.config.ts @@ -21,7 +21,8 @@ export default defineConfig({ '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), - '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor/App': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/AppRoot.tsx'), } }, build: { diff --git a/apps/marketing/public/assets/architecture/shared-rooms.svg b/apps/marketing/public/assets/architecture/shared-rooms.svg new file mode 100644 index 000000000..7cf2cbfe2 --- /dev/null +++ b/apps/marketing/public/assets/architecture/shared-rooms.svg @@ -0,0 +1,72 @@ + + + + + + + + + + Client tier — secrets never leave + + + Browser editor + React SPA · /c/:roomId + + + Your agent + CLI peer · same encryption + + + HKDF key derivation + roomSecret → auth · event · presence + + + AES-256-GCM + Encrypt + decrypt locally + + + + + + + + + + Zero-knowledge boundary + Only ciphertext + HMAC proofs cross + send + relay + + + + room.plannotator.ai | Cloudflare — zero knowledge + + + Worker + Validate roomId · serve SPA shell + + + + route + + + + Durable Object — room engine + + + Event sequencer + Atomic seq + store + + + WebSocket hub + Broadcast to all peers + + + + persist + + + SQLite storage + Ciphertext blobs · verifiers · seq numbers + diff --git a/apps/marketing/src/content/docs/architecture/shared-rooms.md b/apps/marketing/src/content/docs/architecture/shared-rooms.md new file mode 100644 index 000000000..e5411a105 --- /dev/null +++ b/apps/marketing/src/content/docs/architecture/shared-rooms.md @@ -0,0 +1,52 @@ +--- +title: "Shared Rooms" +description: "How Plannotator's live collaboration rooms work, including end-to-end encryption and zero-knowledge architecture." +sidebar: + order: 50 +section: "Architecture" +--- + +# Shared rooms + +Shared rooms let multiple people review a plan together in real time. Annotations, cursors, and presence sync across all participants. The room server never sees your content. + +## Zero-knowledge design + +All plan content is encrypted on your device before it leaves the browser. The server stores and relays ciphertext only. It cannot read your plan, your annotations, or your cursor position. + +When you create a room, the browser generates a random **room secret** and derives three encryption keys from it using HKDF: + +- **Auth key** for the WebSocket handshake proof +- **Event key** for encrypting annotations (AES-256-GCM) +- **Presence key** for encrypting cursor and identity data (AES-256-GCM) + +The room secret lives in the URL fragment (`#key=...`), which browsers never send to the server. Only people who have the link can decrypt the room's content. + +## How it works + +![Shared rooms architecture](/assets/architecture/shared-rooms.svg) + +When a participant sends an annotation, it is encrypted locally with the shared event key, sent over a WebSocket as ciphertext, sequenced by the Durable Object, and broadcast to all other connected clients. Each client decrypts the payload locally using the same event key derived from the shared room secret. + +The server assigns a monotonic sequence number to each event and stores the ciphertext in SQLite. Clients that reconnect replay from their last acknowledged sequence number, so no events are lost. + +## What the server stores + +| Stored on server | Never leaves your browser | +|---|---| +| Room ID, client IDs | Room secret, admin secret | +| Encrypted event blobs | Decrypted plan content | +| Sequence numbers | Annotation text | +| Auth verifiers (hashed) | Cursor positions | + +Auth verifiers are HMAC digests of the room secret. They let the server verify that a connecting client holds the secret without ever seeing the secret itself. + +## Room lifecycle + +Rooms expire automatically after the duration you choose (1, 7, or 30 days). When a room expires or is deleted by its creator, the server purges all stored data. There are no tombstones or soft deletes. + +An admin secret (also in the creator's URL fragment) grants the ability to delete the room early. Like the room secret, it never reaches the server in plaintext. + +## Agent participants + +AI agents can join rooms as first-class peers using the `collab-agent` CLI. They use the same encryption protocol as browsers. Agent cursors appear with a gear icon so human participants can distinguish them. diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index b06931fa8..d4ff60aa0 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -12,14 +12,15 @@ import Footer from '../components/Footer.astro';

Privacy Policy

-

Last updated: April 15, 2026

+

Last updated: May 12, 2026

Plannotator is a local-first, open source project. The core Plannotator experience runs on your machine.

This Privacy Policy explains what data may be processed when you use:

1. Contact

@@ -29,7 +30,7 @@ import Footer from '../components/Footer.astro';

2. What this policy covers

-

This policy covers our website and hosted sharing services.

+

This policy covers our website, hosted sharing services, and live collaboration rooms.

This policy does not cover:

  • your local use of Plannotator on your own machine, except when you choose to use our hosted sharing features
  • @@ -47,6 +48,14 @@ import Footer from '../components/Footer.astro';
  • if you self-host the sharing services, your deployment is separate from ours
+

For live collaboration rooms:

+
    +
  • room content (plans and annotations) is end-to-end encrypted on your device before it reaches the server
  • +
  • the server stores and relays ciphertext only — it cannot read your plans, annotations, or presence data
  • +
  • encryption keys are held in the URL fragment, which is never sent to the server
  • +
  • rooms are deleted automatically after their chosen expiry period, or manually by the room creator at any time
  • +
+

4. What we collect

We try to collect as little as possible.

@@ -55,11 +64,17 @@ import Footer from '../components/Footer.astro';

We do not store unencrypted shared plan contents through that flow.

We do not store the decryption key. Because the key is not stored by our hosted service, we cannot access the unencrypted contents of shared plans through this flow.

-

b. Basic infrastructure data

+

b. Live collaboration room data

+

When you create or join a live collaboration room, the server stores encrypted room state (plan content, annotations, and real-time events) so that participants can collaborate. All encryption and decryption happens on your device.

+

We do not have access to the encryption keys. We cannot read the contents of your plans, annotations, or presence information.

+

Display names and colors chosen for room participation are transmitted as encrypted presence data. We do not collect or store them in plaintext.

+

Room data is deleted automatically when the room expires, or immediately when the room creator deletes it.

+ +

c. Basic infrastructure data

Our hosted services rely on infrastructure providers such as AWS, CloudFront, and Cloudflare. As a result, limited technical data such as IP addresses or request metadata may be processed by those providers as part of delivering the service.

We have configured logging services to be turned off to the extent supported by those providers and services.

-

c. Contact information

+

d. Contact information

If you contact us directly, we may receive your email address and the contents of your message.

5. What we do not collect

@@ -77,8 +92,9 @@ import Footer from '../components/Footer.astro';

7. How we use information

We use information only as needed to:

    -
  • serve the website and hosted sharing features
  • +
  • serve the website, hosted sharing features, and live collaboration rooms
  • deliver shared content through short links
  • +
  • relay encrypted room data between participants
  • keep the service functioning and secure
  • respond to support or contact requests
  • comply with legal obligations
  • @@ -95,6 +111,7 @@ import Footer from '../components/Footer.astro';

    9. Retention

    Encrypted shared payloads are retained only as long as needed for the hosted sharing flow to work.

    +

    Encrypted room data is retained until the room expires or is deleted by the room creator, whichever comes first.

    Contact emails or support messages may be retained as needed to respond to you and keep basic records of those conversations.

    We may retain information longer if required for security, abuse prevention, or legal compliance.

    diff --git a/apps/pi-extension/server-plan.test.ts b/apps/pi-extension/server-plan.test.ts new file mode 100644 index 000000000..21d2f4849 --- /dev/null +++ b/apps/pi-extension/server-plan.test.ts @@ -0,0 +1,173 @@ +/** + * Regression tests for the Pi plan server's approve/deny path: + * + * - saveFinalSnapshot / saveAnnotations throwing must NOT strand the + * decision promise (claim-then-publish hardening, H9/R1). + * - body.permissionMode must be validated via isValidPermissionMode(). + * + * Mirrors the fixes in packages/server/index.ts (Bun). The Pi server is + * the easier integration target because it uses node:http and its + * `startPlanReviewServer` exposes a straightforward decision promise. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startPlanReviewServer } from "./server/serverPlan"; + +const tempDirs: string[] = []; +const originalCwd = process.cwd(); +const originalHome = process.env.HOME; +const originalPort = process.env.PLANNOTATOR_PORT; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function reservePort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to reserve test port")); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalPort === undefined) delete process.env.PLANNOTATOR_PORT; + else process.env.PLANNOTATOR_PORT = originalPort; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +async function bootPlanServer(options: { permissionMode?: string } = {}) { + const homeDir = makeTempDir("plannotator-pi-plan-home-"); + process.env.HOME = homeDir; + process.chdir(homeDir); // Avoid picking up repo git context + process.env.PLANNOTATOR_PORT = String(await reservePort()); + const server = await startPlanReviewServer({ + plan: "# Plan\n\nBody.", + htmlContent: "plan", + origin: "pi", + permissionMode: options.permissionMode ?? "default", + sharingEnabled: false, + }); + return server; +} + +describe("pi plan server: decision-hang regression", () => { + test("approve with a customPath that forces save to throw still resolves the decision", async () => { + const server = await bootPlanServer(); + try { + // Force saveFinalSnapshot/saveAnnotations to throw by pointing the + // custom plan dir at a regular file — mkdirSync recursive will + // fail with ENOTDIR because an ancestor is a file, not a dir. + const fileAsDir = join(makeTempDir("plannotator-block-"), "not-a-dir"); + writeFileSync(fileAsDir, "blocker", "utf-8"); + + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: true, customPath: fileAsDir }, + }), + }); + expect(res.status).toBe(200); + + // Decision promise must resolve even though the save threw. + const decision = await server.waitForDecision(); + expect(decision.approved).toBe(true); + expect(decision.savedPath).toBeUndefined(); + } finally { + server.stop(); + } + }, 10_000); + + test("deny with a customPath that forces save to throw still resolves the decision", async () => { + const server = await bootPlanServer(); + try { + const fileAsDir = join(makeTempDir("plannotator-block-"), "not-a-dir"); + writeFileSync(fileAsDir, "blocker", "utf-8"); + + const res = await fetch(`${server.url}/api/deny`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "nope", + planSave: { enabled: true, customPath: fileAsDir }, + }), + }); + expect(res.status).toBe(200); + + const decision = await server.waitForDecision(); + expect(decision.approved).toBe(false); + expect(decision.savedPath).toBeUndefined(); + expect(decision.feedback).toBe("nope"); + } finally { + server.stop(); + } + }, 10_000); +}); + +describe("pi plan server: permissionMode validation", () => { + test("same-origin body.permissionMode is honored only when isValidPermissionMode passes", async () => { + const server = await bootPlanServer({ permissionMode: "default" }); + try { + // Invalid string → silently dropped; fall back to server startup value. + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: false }, + permissionMode: "rootKeyPleaseAndThankYou", + }), + }); + expect(res.status).toBe(200); + const decision = await server.waitForDecision(); + expect(decision.permissionMode).toBe("default"); + } finally { + server.stop(); + } + }, 10_000); + + test("same-origin body.permissionMode='bypassPermissions' IS honored (valid value)", async () => { + const server = await bootPlanServer({ permissionMode: "default" }); + try { + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: false }, + permissionMode: "bypassPermissions", + }), + }); + expect(res.status).toBe(200); + const decision = await server.waitForDecision(); + expect(decision.permissionMode).toBe("bypassPermissions"); + } finally { + server.stop(); + } + }, 10_000); + +}); diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index d9ab9747f..da2ff910d 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -112,9 +112,10 @@ export async function startAnnotateServer(options: { }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 06ba52754..fdd3f500c 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -35,6 +35,7 @@ import { saveToOctarine, } from "./integrations.js"; import { listenOnPort } from "./network.js"; +import { isValidPermissionMode } from "../generated/collab/validation.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; @@ -134,13 +135,21 @@ export async function startPlanReviewServer(options: { const reviewId = randomUUID(); let resolveDecision!: (result: PlanReviewDecision) => void; const decisionListeners = new Set<(result: PlanReviewDecision) => void | Promise>(); + // Claim-then-publish: claimDecision() sets the flag BEFORE any side + // effects run, so two near-simultaneous POSTs cannot both pass the + // guard and run integrations/saves twice. publishDecision() is + // called after side effects finish; it only resolves the promise + // and notifies listeners. Mirrors packages/server/index.ts. let decisionSettled = false; const decisionPromise = new Promise((r) => { resolveDecision = r; }); - const publishDecision = (result: PlanReviewDecision): boolean => { + const claimDecision = (): boolean => { if (decisionSettled) return false; decisionSettled = true; + return true; + }; + const publishDecision = (result: PlanReviewDecision): boolean => { resolveDecision(result); for (const listener of decisionListeners) { Promise.resolve(listener(result)).catch((error) => { @@ -246,9 +255,10 @@ export async function startPlanReviewServer(options: { }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean; pfmReminder?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean; pfmReminder?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; @@ -362,7 +372,7 @@ export async function startPlanReviewServer(options: { } json(res, { ok: true, results }); } else if (url.pathname === "/api/approve" && req.method === "POST") { - if (decisionSettled) { + if (!claimDecision()) { json(res, { ok: true, duplicate: true }); return; } @@ -375,14 +385,15 @@ export async function startPlanReviewServer(options: { const body = await parseBody(req); if (body.feedback) feedback = body.feedback as string; if (body.agentSwitch) agentSwitch = body.agentSwitch as string; - if (body.permissionMode) - requestedPermissionMode = body.permissionMode as string; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; planSaveCustomPath = ps.customPath; } - // Run note integrations in parallel + // Validate body.permissionMode shape so an invalid value + // can't silently fall through to the hook. + if (isValidPermissionMode(body.permissionMode)) + requestedPermissionMode = body.permissionMode; const integrationResults: Record = {}; const integrationPromises: Promise[] = []; const obsConfig = body.obsidian as ObsidianConfig | undefined; @@ -417,20 +428,32 @@ export async function startPlanReviewServer(options: { } catch (err) { console.error(`[Integration] Error:`, err); } - // Save annotations and final snapshot + // Save annotations and final snapshot. The claim is already set + // above, so we MUST reach publishDecision() below — otherwise + // the awaiting hook hangs forever and retries are rejected as + // duplicates. Persistence is best-effort: log and continue. let savedPath: string | undefined; if (planSaveEnabled) { - const annotations = feedback || ""; - if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "approved", - options.plan, - annotations, - planSaveCustomPath, - ); + try { + const annotations = feedback || ""; + if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "approved", + options.plan, + annotations, + planSaveCustomPath, + ); + } catch (err) { + console.error(`[plan-save] approve persistence failed:`, err); + } + } + try { + deleteDraft(draftKey); + } catch (err) { + console.error(`[draft] delete failed:`, err); } - deleteDraft(draftKey); + // Resolution order: client request body > server startup value. const effectivePermissionMode = requestedPermissionMode || options.permissionMode; publishDecision({ approved: true, @@ -441,7 +464,7 @@ export async function startPlanReviewServer(options: { }); json(res, { ok: true, savedPath }); } else if (url.pathname === "/api/deny" && req.method === "POST") { - if (decisionSettled) { + if (!claimDecision()) { json(res, { ok: true, duplicate: true }); return; } @@ -461,16 +484,24 @@ export async function startPlanReviewServer(options: { } let savedPath: string | undefined; if (planSaveEnabled) { - saveAnnotations(slug, feedback, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "denied", - options.plan, - feedback, - planSaveCustomPath, - ); + try { + saveAnnotations(slug, feedback, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "denied", + options.plan, + feedback, + planSaveCustomPath, + ); + } catch (err) { + console.error(`[plan-save] deny persistence failed:`, err); + } + } + try { + deleteDraft(draftKey); + } catch (err) { + console.error(`[draft] delete failed:`, err); } - deleteDraft(draftKey); publishDecision({ approved: false, feedback, savedPath }); json(res, { ok: true, savedPath }); } else { diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 85cda4172..75263060b 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -967,9 +967,10 @@ export async function startReviewServer(options: { json(res, { error: "No file access available" }, 400); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 684c420f1..f69538aa5 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -11,6 +11,13 @@ for f in feedback-templates prompts review-core jj-core vcs-core review-args sto printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done +# Vendor collab submodule(s) needed by the Pi server. +mkdir -p generated/collab +for f in validation; do + src="../../packages/shared/collab/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/collab/%s.ts\n' "$f" | cat - "$src" > "generated/collab/$f.ts" +done + # Vendor review agent modules from packages/server/ — rewrite imports for generated/ layout for f in agent-review-message codex-review claude-review path-utils; do src="../../packages/server/$f.ts" diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 93ef3e28b..1b3c3a05f 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "@/*": ["./*"], "@plannotator/ui/*": ["../../packages/ui/*"], - "@plannotator/editor": ["../../packages/editor/App.tsx"], + "@plannotator/editor": ["../../packages/editor/AppRoot.tsx"], + "@plannotator/editor/App": ["../../packages/editor/App.tsx"], "@plannotator/editor/*": ["../../packages/editor/*"] }, "allowImportingTsExtensions": true, diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index 822b099cf..226d96df1 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ '@': path.resolve(__dirname, '.'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), - '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor/App': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/AppRoot.tsx'), } }, build: { diff --git a/apps/room-service/core/auth.test.ts b/apps/room-service/core/auth.test.ts new file mode 100644 index 000000000..af1c4bf3e --- /dev/null +++ b/apps/room-service/core/auth.test.ts @@ -0,0 +1,117 @@ +/** + * End-to-end auth proof verification tests. + * + * These tests act as an external client: they use shared/collab/client + * helpers (deriveRoomKeys, computeAuthProof) to simulate a connecting + * browser/agent, then verify using the server-side verifyAuthProof. + * + * This proves the full auth chain: secret → keys → verifier → challenge → proof → verify. + */ + +import { describe, expect, test } from 'bun:test'; +import { + deriveRoomKeys, + computeRoomVerifier, + computeAuthProof, + verifyAuthProof, + generateNonce, + generateChallengeId, +} from '@plannotator/shared/collab/client'; + +// Stable test secrets +const ROOM_SECRET = new Uint8Array(32); +ROOM_SECRET.fill(0xab); + +const ROOM_ID = 'test-room-auth'; +const CLIENT_ID = 'client-123'; + +describe('auth proof verification (end-to-end)', () => { + test('valid proof is accepted', async () => { + // Client side: derive keys, compute verifier and proof + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const proof = await computeAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Server side: verify the proof using stored verifier + const valid = await verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(true); + }); + + test('wrong proof is rejected', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Compute proof with wrong client ID + const proof = await computeAuthProof(verifier, ROOM_ID, 'wrong-client', challengeId, nonce); + + // Verify with correct client ID — should fail + const valid = await verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); + + test('wrong roomId is rejected', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const proof = await computeAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Verify with wrong roomId + const valid = await verifyAuthProof(verifier, 'wrong-room', CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); + + test('malformed proof returns false (does not throw)', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Garbage proof strings + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, 'A')) + .resolves.toBe(false); + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, '!@#$')) + .resolves.toBe(false); + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, '')) + .resolves.toBe(false); + }); + + test('different room secrets produce incompatible verifiers', async () => { + const secret2 = new Uint8Array(32); + secret2.fill(0xcd); + + const keys1 = await deriveRoomKeys(ROOM_SECRET); + const keys2 = await deriveRoomKeys(secret2); + + const verifier1 = await computeRoomVerifier(keys1.authKey, ROOM_ID); + const verifier2 = await computeRoomVerifier(keys2.authKey, ROOM_ID); + + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Proof computed with secret1's verifier + const proof = await computeAuthProof(verifier1, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Verify with secret2's verifier — should fail + const valid = await verifyAuthProof(verifier2, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); +}); + +describe('challenge expiry detection', () => { + test('current timestamp is within expiry', () => { + const expiresAt = Date.now() + 30_000; + expect(Date.now() <= expiresAt).toBe(true); + }); + + test('past timestamp is expired', () => { + const expiresAt = Date.now() - 1000; + expect(Date.now() > expiresAt).toBe(true); + }); +}); diff --git a/apps/room-service/core/cors.test.ts b/apps/room-service/core/cors.test.ts new file mode 100644 index 000000000..4b21f4f76 --- /dev/null +++ b/apps/room-service/core/cors.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { corsHeaders, getAllowedOrigins, isLocalhostOrigin } from './cors'; + +describe('getAllowedOrigins', () => { + test('returns defaults when no env value', () => { + const origins = getAllowedOrigins(); + expect(origins).toEqual(['https://room.plannotator.ai']); + }); + + test('parses comma-separated env value', () => { + const origins = getAllowedOrigins('https://a.com, https://b.com'); + expect(origins).toEqual(['https://a.com', 'https://b.com']); + }); +}); + +describe('isLocalhostOrigin', () => { + test('matches http localhost with port', () => { + expect(isLocalhostOrigin('http://localhost:3001')).toBe(true); + expect(isLocalhostOrigin('http://localhost:57589')).toBe(true); + }); + + test('matches http localhost without port', () => { + expect(isLocalhostOrigin('http://localhost')).toBe(true); + }); + + test('matches https localhost', () => { + expect(isLocalhostOrigin('https://localhost:8443')).toBe(true); + }); + + test('matches 127.0.0.1 with port', () => { + expect(isLocalhostOrigin('http://127.0.0.1:3001')).toBe(true); + }); + + test('matches [::1] with port', () => { + expect(isLocalhostOrigin('http://[::1]:3001')).toBe(true); + }); + + test('matches 127.0.0.1 without port', () => { + expect(isLocalhostOrigin('http://127.0.0.1')).toBe(true); + }); + + test('rejects non-localhost', () => { + expect(isLocalhostOrigin('https://evil.com')).toBe(false); + expect(isLocalhostOrigin('https://localhost.evil.com')).toBe(false); + expect(isLocalhostOrigin('http://127.0.0.2:3001')).toBe(false); + }); +}); + +describe('corsHeaders', () => { + const prodOrigins = ['https://room.plannotator.ai']; + + test('allows listed production origin', () => { + const headers = corsHeaders('https://room.plannotator.ai', prodOrigins); + expect(headers['Access-Control-Allow-Origin']).toBe('https://room.plannotator.ai'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('localhost allowed when flag is true', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins, true); + expect(headers['Access-Control-Allow-Origin']).toBe('http://localhost:57589'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('localhost rejected when flag is false', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins, false); + expect(headers).toEqual({}); + }); + + test('localhost rejected when flag is not provided', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins); + expect(headers).toEqual({}); + }); + + test('rejects unlisted non-localhost origin', () => { + const headers = corsHeaders('https://evil.example', prodOrigins, true); + expect(headers).toEqual({}); + }); + + test('allows any origin with wildcard', () => { + const headers = corsHeaders('https://anything.com', ['*']); + expect(headers['Access-Control-Allow-Origin']).toBe('https://anything.com'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('returns empty for no origin match', () => { + const headers = corsHeaders('', prodOrigins); + expect(headers).toEqual({}); + }); + + test('all allowed responses include Vary: Origin', () => { + const h1 = corsHeaders('https://room.plannotator.ai', prodOrigins); + const h2 = corsHeaders('http://localhost:3001', prodOrigins, true); + const h3 = corsHeaders('https://x.com', ['*']); + expect(h1['Vary']).toBe('Origin'); + expect(h2['Vary']).toBe('Origin'); + expect(h3['Vary']).toBe('Origin'); + }); +}); diff --git a/apps/room-service/core/cors.ts b/apps/room-service/core/cors.ts new file mode 100644 index 000000000..7fb99612d --- /dev/null +++ b/apps/room-service/core/cors.ts @@ -0,0 +1,49 @@ +/** + * CORS handling for room.plannotator.ai. + * + * Localhost origins are allowed only when ALLOW_LOCALHOST_ORIGINS is explicitly + * set to "true". This is intentional product behavior: Plannotator runs locally + * on unpredictable ports and needs to call room.plannotator.ai/api/rooms when + * the creator starts a live room. The room service still stores only ciphertext + * and verifiers — room content access depends on the URL fragment secret. + */ + +const BASE_CORS_HEADERS = { + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +}; + +/** Matches localhost, 127.0.0.1, and [::1] with optional port. */ +const LOOPBACK_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; + +export function getAllowedOrigins(envValue?: string): string[] { + if (envValue) { + return envValue.split(',').map((o) => o.trim()); + } + return ['https://room.plannotator.ai']; +} + +export function isLocalhostOrigin(origin: string): boolean { + return LOOPBACK_RE.test(origin); +} + +export function corsHeaders( + requestOrigin: string, + allowedOrigins: string[], + allowLocalhostOrigins: boolean = false, +): Record { + const allowed = + allowedOrigins.includes(requestOrigin) || + allowedOrigins.includes('*') || + (allowLocalhostOrigins && isLocalhostOrigin(requestOrigin)); + + if (allowed) { + return { + ...BASE_CORS_HEADERS, + 'Access-Control-Allow-Origin': requestOrigin, + 'Vary': 'Origin', + }; + } + return {}; +} diff --git a/apps/room-service/core/csp.test.ts b/apps/room-service/core/csp.test.ts new file mode 100644 index 000000000..aa30ea291 --- /dev/null +++ b/apps/room-service/core/csp.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test'; +import { ROOM_CSP, handleRequest } from './handler'; + +/** Parse a CSP directive into its individual tokens. */ +function directiveTokens(csp: string, directive: string): string[] { + const d = csp + .split(';') + .map(s => s.trim()) + .find(s => s.startsWith(directive)); + if (!d) return []; + return d.split(/\s+/).slice(1); // drop the directive name itself +} + +describe('ROOM_CSP constant', () => { + test('is a non-empty string', () => { + expect(typeof ROOM_CSP).toBe('string'); + expect(ROOM_CSP.length).toBeGreaterThan(0); + }); + + test("default-src is 'self'", () => { + expect(ROOM_CSP).toContain("default-src 'self'"); + }); + + test("script-src allows 'self' and 'wasm-unsafe-eval' only", () => { + const tokens = directiveTokens(ROOM_CSP, 'script-src'); + expect(tokens).toContain("'self'"); + expect(tokens).toContain("'wasm-unsafe-eval'"); + // Must NOT contain plain 'unsafe-eval' or 'unsafe-inline'. + expect(tokens).not.toContain("'unsafe-eval'"); + expect(tokens).not.toContain("'unsafe-inline'"); + }); + + test('blocks object embeds', () => { + expect(ROOM_CSP).toContain("object-src 'none'"); + }); + + test('blocks base-uri injection', () => { + expect(ROOM_CSP).toContain("base-uri 'none'"); + }); + + test('blocks framing (clickjacking)', () => { + expect(ROOM_CSP).toContain("frame-ancestors 'none'"); + }); + + test('blocks form submissions', () => { + expect(ROOM_CSP).toContain("form-action 'none'"); + }); + + test('does NOT allow localhost HTTP connections', () => { + // The room origin should not have blanket fetch access to any + // local HTTP service; an XSS injection would otherwise exfiltrate + // to loopback listeners. WebSocket entries below are intentionally + // scoped to `ws://` only (HTTP loopback remains closed). + const tokens = directiveTokens(ROOM_CSP, 'connect-src'); + expect(tokens).not.toContain('http://localhost:*'); + expect(tokens).not.toContain('http://127.0.0.1:*'); + expect(tokens).not.toContain('http://[::1]:*'); + }); + + test('allows scoped localhost WebSocket connections (cross-port dev)', () => { + expect(ROOM_CSP).toContain('ws://localhost:*'); + expect(ROOM_CSP).toContain('ws://127.0.0.1:*'); + expect(ROOM_CSP).toContain('ws://[::1]:*'); + }); + + test('does NOT allow blanket https: / ws: / wss: in connect-src', () => { + // `'self'` already covers same-origin wss:/ws: in prod and dev. + // Blanket schemes would allow post-XSS exfiltration to any host on + // that scheme — same reasoning that excludes blanket https:. + const tokens = directiveTokens(ROOM_CSP, 'connect-src'); + expect(tokens).not.toContain('https:'); + expect(tokens).not.toContain('ws:'); + expect(tokens).not.toContain('wss:'); + }); + + test('img-src allows remote markdown images (https:)', () => { + // Remote `![alt](https://...)` in a plan document renders as a + // plain and must not be blocked. Annotation + // attachments remain stripped at room-create time, so this allowance + // only covers document-level markdown images. + const tokens = directiveTokens(ROOM_CSP, 'img-src'); + expect(tokens).toContain("'self'"); + expect(tokens).toContain('https:'); + expect(tokens).toContain('data:'); + expect(tokens).toContain('blob:'); + }); + + test('does NOT include upgrade-insecure-requests', () => { + expect(ROOM_CSP).not.toContain('upgrade-insecure-requests'); + }); + + test('allows Google Fonts', () => { + expect(ROOM_CSP).toContain('https://fonts.googleapis.com'); + expect(ROOM_CSP).toContain('https://fonts.gstatic.com'); + }); +}); + +describe('serveIndexHtml headers (fallback path, no ASSETS)', () => { + // Minimal env with no ASSETS binding — exercises the fallback + // HTML path inside handleRequest, which is the cheapest way to + // assert the headers without needing a Durable Object namespace. + const minimalEnv = { + ROOM: {} as never, // unused by the room-shell path + ALLOWED_ORIGINS: 'https://room.plannotator.ai', + ALLOW_LOCALHOST_ORIGINS: 'true', + BASE_URL: 'https://room.plannotator.ai', + }; + const cors = { + 'Access-Control-Allow-Origin': '*', + }; + + async function getRoom(roomId = 'AAAAAAAAAAAAAAAAAAAAAA'): Promise { + const req = new Request(`https://room.plannotator.ai/c/${roomId}`, { + method: 'GET', + }); + return handleRequest(req, minimalEnv, cors); + } + + test('returns 200 with Content-Security-Policy', async () => { + const res = await getRoom(); + expect(res.status).toBe(200); + const csp = res.headers.get('Content-Security-Policy'); + expect(csp).not.toBeNull(); + expect(csp).toContain("default-src 'self'"); + }); + + test('returns Cache-Control: no-store', async () => { + const res = await getRoom(); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); + + test('returns Referrer-Policy: no-referrer', async () => { + const res = await getRoom(); + expect(res.headers.get('Referrer-Policy')).toBe('no-referrer'); + }); + + test('returns text/html content type', async () => { + const res = await getRoom(); + expect(res.headers.get('Content-Type')).toContain('text/html'); + }); +}); diff --git a/apps/room-service/core/handler.ts b/apps/room-service/core/handler.ts new file mode 100644 index 000000000..ca2dfd8e7 --- /dev/null +++ b/apps/room-service/core/handler.ts @@ -0,0 +1,375 @@ +/** + * HTTP route dispatch for room.plannotator.ai. + * + * Routes requests to the appropriate Durable Object or returns + * static responses. Does NOT apply CORS to WebSocket upgrades. + */ + +import type { Env } from './types'; +import { isRoomId, validateCreateRoomRequest, isValidationError } from './validation'; +import { safeLog } from './log'; +import { urlToMarkdown } from '@plannotator/shared/url-to-markdown'; + +const ROOM_PATH_RE = /^\/c\/([^/]+)$/; +const WS_PATH_RE = /^\/ws\/([^/]+)$/; + +export async function handleRequest( + request: Request, + env: Env, + cors: Record, +): Promise { + const url = new URL(request.url); + const { pathname } = url; + const method = request.method; + + // CORS preflight + if (method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + // Health check + if (pathname === '/health' && method === 'GET') { + return Response.json({ ok: true }, { headers: cors }); + } + + // Room creation + if (pathname === '/api/rooms' && method === 'POST') { + return handleCreateRoom(request, env, cors); + } + + // URL → markdown conversion (landing page proxy) + if (pathname === '/api/fetch-markdown' && method === 'POST') { + return handleFetchMarkdown(request, cors); + } + + // WebSocket upgrade — matched before asset/SPA routes so a stray ws/* + // under the asset binding can't be mistaken for a file fetch. + const wsMatch = pathname.match(WS_PATH_RE); + if (wsMatch && method === 'GET') { + return handleWebSocket(request, env, wsMatch[1], cors); + } + + // Hashed static assets — produced by `vite build` into ./public/assets/. + // Filenames include a content hash, so we set far-future immutable + // Cache-Control: chunks invalidate by name, never by TTL. Headers from + // the asset response (Content-Type, ETag, Content-Encoding) are + // preserved; we only override CORS + Cache-Control. + // Static root-level assets (favicon.svg, banner_lite.webp, sprite.png, etc.). + // Vite copies these from the publicDir into the build output root. + // Served with a 1-day cache — not hashed so immutable isn't safe. + const isRootStaticAsset = method === 'GET' && /^\/(favicon\.svg|[^/]+\.(webp|png|ico|svg|md))$/.test(pathname); + if (isRootStaticAsset) { + if (!env.ASSETS) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const assetRes = await env.ASSETS.fetch(request); + // Pass a real miss through as 404, but let 304 Not Modified + // responses flow through — `fetch.ok` treats 304 as "not ok" + // (it's outside 200-299), so returning 404 on 304 would force + // the browser to abandon its cached favicon and re-download + // on every revalidation. + if (!assetRes.ok && assetRes.status !== 304) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Cache-Control', 'public, max-age=86400'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + + if (pathname.startsWith('/assets/') && method === 'GET') { + if (!env.ASSETS) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const assetRes = await env.ASSETS.fetch(request); + if (!assetRes.ok) { + // Surface the real status (404/403/etc.) rather than pretending + // everything is fine. CORS still attached so the browser exposes + // the response to the page's fetch logic. + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + + // Room SPA shell — /c/:roomId rewrites to /index.html so the chunked + // Vite bundle can boot with the original path still visible to the + // client JS (useRoomMode reads window.location.pathname to extract + // roomId, and parseRoomUrl reads the fragment for the room secret). + // + // Cache-Control: no-store — index.html references hashed chunk URLs + // that change on every deploy. Caching it would pin clients to stale + // chunk references and break after the next release. The immutable + // caching for /assets/* is what preserves the warm-visit performance; + // this HTML is tiny. + // + // Referrer-Policy: no-referrer strips the path (which contains the + // roomId) from Referer on any outbound subresource fetch. Fragments + // are never in Referer in any browser, so this is defense-in-depth + // for the path, not the secret itself. + const roomMatch = pathname.match(ROOM_PATH_RE); + if (roomMatch && method === 'GET') { + const roomId = roomMatch[1]; + if (!isRoomId(roomId)) { + return new Response('Not Found', { status: 404, headers: cors }); + } + return serveIndexHtml(request, env, cors); + } + + // Landing page — room creation from uploaded document. The entry-level + // path switch in entry.tsx renders for pathname '/'. + if (pathname === '/' && method === 'GET') { + return serveIndexHtml(request, env, cors); + } + + return Response.json( + { error: 'Not found. Valid paths: GET /, GET /health, GET /c/:id, POST /api/rooms, POST /api/fetch-markdown, GET /ws/:id, GET /assets/*' }, + { status: 404, headers: cors }, + ); +} + +/** + * Content Security Policy for the room HTML shell. + * + * Applied ONLY to the document response (/index.html), not to API or + * asset responses. The browser evaluates CSP from the document. + * + * Rationale for each directive: + * default-src 'self' — lockdown baseline + * script-src 'self' 'wasm-unsafe-eval' + * — Vite chunks + Graphviz WASM + * style-src 'self' 'unsafe-inline' https://fonts.googleapis.com + * — app CSS + Google Fonts + inline styles + * font-src 'self' https://fonts.gstatic.com + * — Google font files + * img-src 'self' https: data: blob: + * — icons, blob previews, and remote + * markdown document images (e.g. + * `![diagram](https://example/a.png)`) + * which Viewer renders as plain . + * connect-src 'self' ws://localhost:* ws://127.0.0.1:* ws://[::1]:* + * — same-origin Worker API/WebSocket + * + cross-port localhost dev WS + * worker-src 'self' blob: — defensive for libs using blob workers + * object-src 'none' — no plugins/objects + * base-uri 'none' — prevent tag injection + * frame-ancestors 'none' — no clickjacking/embedding + * form-action 'none' — no form submissions expected + */ +export const ROOM_CSP = [ + "default-src 'self'", + // 'wasm-unsafe-eval' needed for @viz-js/viz (Graphviz WASM build). + // NOT 'unsafe-eval' — only WebAssembly compilation is allowed. + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + // Remote markdown document images (e.g. `![diagram](https://example/a.png)`) + // are a supported plan-content feature — Viewer renders them as plain + // ``. Allowing blanket `https:` here is a known + // tradeoff: an injected script could beacon via image URLs. Accepted + // because the product supports remote plan images, and the more + // exfil-capable channels (fetch / WebSocket) stay locked down via + // `connect-src 'self' + scoped localhost`. + // Annotation image attachments remain stripped before sending to the + // room (stripRoomAnnotationImages), so only document-level markdown + // images exercise this allowance. + "img-src 'self' https: data: blob:", + // Production: `'self'` covers the same-origin WebSocket + // (wss://room.plannotator.ai/ws/) per the CSP spec. + // + // Development: wrangler dev serves both the room shell and the + // WebSocket on the same localhost port, so `'self'` covers that + // too. Cross-port local dev (shell on one port, WebSocket on + // another) still needs explicit ws:// localhost entries. + // + // Blanket https: / ws: / wss: are intentionally omitted — + // widening the scheme would give any post-XSS injection an + // unrestricted exfiltration surface. + "connect-src 'self' ws://localhost:* ws://127.0.0.1:* ws://[::1]:*", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "form-action 'none'", + // upgrade-insecure-requests is intentionally omitted because + // wrangler dev serves the shell + WebSocket over `ws://localhost`, + // and this directive rewrites ws:// → wss:// (which breaks local + // development). Production only makes same-origin wss:// + // connections, so the directive would be a no-op there anyway. +].join('; '); + +/** + * Fetch and serve /index.html from the Wrangler asset binding with the + * headers the room shell needs: CSP, CORS, no-store cache, + * Referrer-Policy, and an HTML content type. Falls back to a minimal + * inline HTML when ASSETS is unbound (local test environments that + * don't run Wrangler). + */ +async function serveIndexHtml( + request: Request, + env: Env, + cors: Record, +): Promise { + if (env.ASSETS) { + const assetUrl = new URL(request.url); + assetUrl.pathname = '/index.html'; + const assetReq = new Request(assetUrl, { method: 'GET', headers: request.headers }); + const assetRes = await env.ASSETS.fetch(assetReq); + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Content-Security-Policy', ROOM_CSP); + headers.set('Referrer-Policy', 'no-referrer'); + headers.set('Content-Type', 'text/html; charset=utf-8'); + headers.set('Cache-Control', 'no-store'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + // Fallback for local/test environments without an ASSETS binding. + return new Response( + `Plannotator Room

    Room shell (test fallback; ASSETS binding unavailable)

    `, + { + status: 200, + headers: { + ...cors, + 'Content-Security-Policy': ROOM_CSP, + 'Content-Type': 'text/html; charset=utf-8', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }, + }, + ); +} + +// --------------------------------------------------------------------------- +// Room Creation +// +// PRODUCTION HARDENING (required before public deployment, not in V1 scope): +// `POST /api/rooms` is intentionally unauthenticated in the V1 protocol. A +// room is a capability-token pair (roomSecret + adminSecret) the creator +// generates locally; this endpoint only asserts existence on the server, not +// identity. That means anyone who can reach the Worker can create rooms — +// fine for local dev and gated staging, NOT fine for the open internet. +// +// Before this Worker is exposed publicly it MUST be gated by one of: +// - Cloudflare rate limiting / WAF rule keyed on source IP + path +// - application-level throttle at the Worker entry (shared Durable Object +// counter or KV-based token bucket) +// - authenticated proxy (plannotator.ai app calls on behalf of signed-in users) +// +// CORS is NOT abuse protection — it's a browser same-origin policy and does +// nothing to a direct HTTP client. Any future reviewer flagging "this +// endpoint is unauthenticated" should be pointed HERE. Production hardening +// (rate-limit POST /api/rooms) is the intended gate; the protocol design +// accommodates adding it without client changes. +// --------------------------------------------------------------------------- + +async function handleCreateRoom( + request: Request, + env: Env, + cors: Record, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400, headers: cors }); + } + + const result = validateCreateRoomRequest(body); + if (isValidationError(result)) { + return Response.json({ error: result.error }, { status: result.status, headers: cors }); + } + + safeLog('handler:create-room', { roomId: result.roomId }); + + // Forward to the Durable Object + const id = env.ROOM.idFromName(result.roomId); + const stub = env.ROOM.get(id); + const doResponse = await stub.fetch( + new Request('http://do/create', { + method: 'POST', + body: JSON.stringify(result), + headers: { 'Content-Type': 'application/json' }, + }), + ); + + // Re-wrap DO response with CORS headers + const responseBody = await doResponse.text(); + return new Response(responseBody, { + status: doResponse.status, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); +} + +// --------------------------------------------------------------------------- +// WebSocket Upgrade +// --------------------------------------------------------------------------- + +async function handleWebSocket( + request: Request, + env: Env, + roomId: string, + cors: Record, +): Promise { + // Verify WebSocket upgrade header. RFC 6455 specifies the token + // is case-insensitive; browsers send lowercase but standards- + // conformant non-browser clients may send `WebSocket` or `WEBSOCKET`. + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { + return Response.json( + { error: 'Expected WebSocket upgrade' }, + { status: 426, headers: cors }, + ); + } + + // Validate roomId BEFORE idFromName(). idFromName on arbitrary attacker + // input would instantiate a fresh DO and hit storage on every request — + // a cheap abuse surface. Reject malformed IDs up front. + if (!isRoomId(roomId)) { + return Response.json( + { error: 'Invalid roomId' }, + { status: 400, headers: cors }, + ); + } + + // Forward to the Durable Object — no CORS on WebSocket upgrade + const id = env.ROOM.idFromName(roomId); + const stub = env.ROOM.get(id); + return stub.fetch(request); +} + +// --------------------------------------------------------------------------- +// URL → Markdown +// --------------------------------------------------------------------------- + +async function handleFetchMarkdown( + request: Request, + cors: Record, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400, headers: cors }); + } + + if (body === null || typeof body !== 'object' || typeof (body as Record).url !== 'string') { + return Response.json({ error: 'Missing required field: url' }, { status: 400, headers: cors }); + } + + const url = (body as Record).url as string; + + if (!/^https:\/\//i.test(url)) { + return Response.json({ error: 'Only https:// URLs are supported' }, { status: 400, headers: cors }); + } + + try { + const result = await urlToMarkdown(url, { useJina: true }); + return Response.json({ markdown: result.markdown, source: result.source }, { headers: cors }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Fetch failed'; + return Response.json({ error: message }, { status: 502, headers: cors }); + } +} diff --git a/apps/room-service/core/log.ts b/apps/room-service/core/log.ts new file mode 100644 index 000000000..11483f383 --- /dev/null +++ b/apps/room-service/core/log.ts @@ -0,0 +1,30 @@ +/** + * Redaction-aware logging for the room service. + * + * Redacts proofs, verifiers, ciphertext, and message bodies from logs. + */ + +const REDACTED_KEYS = new Set([ + 'roomVerifier', + 'adminVerifier', + 'proof', + 'adminProof', + 'ciphertext', + 'initialSnapshotCiphertext', + 'snapshotCiphertext', + 'nonce', +]); + +/** Shallow-clone an object, replacing sensitive field values with "[REDACTED]". */ +export function redactForLog(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = REDACTED_KEYS.has(key) ? '[REDACTED]' : value; + } + return result; +} + +/** Log with sensitive fields redacted. */ +export function safeLog(label: string, obj: Record): void { + console.log(label, redactForLog(obj)); +} diff --git a/apps/room-service/core/room-do.ts b/apps/room-service/core/room-do.ts new file mode 100644 index 000000000..1389ec589 --- /dev/null +++ b/apps/room-service/core/room-do.ts @@ -0,0 +1,956 @@ +/** + * Plannotator Room Durable Object. + * + * Uses Cloudflare Workers WebSocket Hibernation API. + * All per-connection state lives in WebSocket attachments + * (survives DO hibernation). + * + * Implements: room creation, WebSocket auth, event sequencing, + * presence relay, reconnect replay, admin commands, lifecycle enforcement. + * + * Zero-knowledge: stores/relays ciphertext only. Never needs roomSecret, + * eventKey, presenceKey, or plaintext content. + */ + +import type { + AuthChallenge, + AuthResponse, + AuthAccepted, + AdminChallenge, + CreateRoomRequest, + CreateRoomResponse, + ServerEnvelope, + SequencedEnvelope, + RoomTransportMessage, +} from '@plannotator/shared/collab'; +import { verifyAuthProof, verifyAdminProof, generateChallengeId, generateClientId, generateNonce } from '@plannotator/shared/collab'; +// Shared terminal close-signal constants — client treats this pair as +// "the link no longer resolves" (admin delete, auto-expiry, or a room +// that never existed — from the client's perspective, indistinguishable). +import { AdminErrorCode, WS_CLOSE_REASON_ROOM_UNAVAILABLE, WS_CLOSE_ROOM_UNAVAILABLE } from '@plannotator/shared/collab/constants'; +import { DurableObject } from 'cloudflare:workers'; +import type { Env, RoomDurableState, WebSocketAttachment } from './types'; +import { clampExpiryDays, hasRoomExpired, validateServerEnvelope, validateAdminCommandEnvelope, isValidationError } from './validation'; +import { safeLog } from './log'; + +const CHALLENGE_TTL_MS = 30_000; +const ADMIN_CHALLENGE_TTL_MS = 30_000; +const DELETE_BATCH_SIZE = 128; // Cloudflare DO storage.delete() max keys per call +/** + * Page size for reconnect replay. Bounds peak DO memory during replay — + * storage.list() without a limit reads all matching rows at once, which + * fails for large/noisy rooms. Each page is streamed out to the WebSocket, + * then released. 128 is a conservative starting point well within DO memory + * budgets even if each event ciphertext is a few KB. + */ +const REPLAY_PAGE_SIZE = 128; + +/** + * Abuse/failure containment: per-room WebSocket cap. Not about expected + * normal room sizes — V1 rooms are small — but bounds broadcast fanout + * and runaway reconnect loops if a misbehaving client (or attacker with + * the room URL) opens sockets without releasing them. Returns 429 Too + * Many Requests when exceeded; honest clients see this only if the room + * is already saturated. + */ +const MAX_CONNECTIONS_PER_ROOM = 100; + +/** Pre-auth length caps on the auth.response message. Real values are + * much smaller (challengeId ~22 chars, clientId server-assigned, proof + * ~43 chars for HMAC-SHA-256 base64url). Generous caps bound the + * unauthenticated work the server is willing to do per connection. */ +const AUTH_CHALLENGE_ID_MAX_LENGTH = 64; +const AUTH_CLIENT_ID_MAX_LENGTH = 64; +const AUTH_PROOF_MAX_LENGTH = 128; + +// WebSocket close codes (room-service-internal; shared close codes come from constants.ts) +const WS_CLOSE_AUTH_REQUIRED = 4001; +const WS_CLOSE_UNKNOWN_CHALLENGE = 4002; +const WS_CLOSE_CHALLENGE_EXPIRED = 4003; +const WS_CLOSE_INVALID_PROOF = 4004; +const WS_CLOSE_PROTOCOL_ERROR = 4005; + +/** Zero-pad a seq number to 10 digits for lexicographic storage ordering. */ +function padSeq(seq: number): string { + return String(seq).padStart(10, '0'); +} + +export class RoomDurableObject extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/create' && request.method === 'POST') { + return this.handleCreate(request); + } + + if (request.headers.get('Upgrade') === 'websocket') { + return this.handleWebSocketUpgrade(request); + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } + + // --------------------------------------------------------------------------- + // Room Creation + // --------------------------------------------------------------------------- + + private async handleCreate(request: Request): Promise { + let body: CreateRoomRequest; + try { + body = await request.json() as CreateRoomRequest; + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const existing = await this.ctx.storage.get('room'); + if (existing) { + // Lazy-expiry backstop: if somehow the alarm didn't fire (e.g. the + // room outlived its deadline without anyone connecting AND without + // the alarm landing), purge here and allow the new create to + // supplant the stale roomId. The alarm is the primary cleanup + // path — this is defense in depth. + if (hasRoomExpired(existing.expiresAt)) { + await this.purgeRoom('create-preempted-expired'); + // fall through to create a fresh room at this id + } else { + return Response.json({ error: 'Room already exists' }, { status: 409 }); + } + } + + const expiryDays = clampExpiryDays(body.expiresInDays); + const expiresAt = expiryDays !== null ? Date.now() + expiryDays * 24 * 60 * 60 * 1000 : null; + + const state: RoomDurableState = { + roomId: body.roomId, + roomVerifier: body.roomVerifier, + adminVerifier: body.adminVerifier, + seq: 0, + earliestRetainedSeq: 1, + snapshotCiphertext: body.initialSnapshotCiphertext, + snapshotSeq: 0, + expiresAt, + }; + + try { + await this.ctx.storage.put('room', state); + } catch (e) { + safeLog('room:create-storage-error', { roomId: body.roomId, error: String(e) }); + return Response.json({ error: 'Failed to store room state' }, { status: 507 }); + } + + // Schedule auto-purge alarm. Skipped for "never" rooms (expiresAt null). + // `setAlarm` overwrites any pending alarm, which is what we want if + // this create supplanted an expired-but-alarm-less room above. + if (expiresAt !== null) { + try { + await this.ctx.storage.setAlarm(expiresAt); + } catch (e) { + safeLog('room:set-alarm-error', { roomId: body.roomId, error: String(e) }); + } + } + + const base = new URL(this.env.BASE_URL || 'https://room.plannotator.ai'); + const wsScheme = base.protocol === 'https:' ? 'wss:' : 'ws:'; + + const response: CreateRoomResponse = { + roomId: body.roomId, + seq: 0, + snapshotSeq: 0, + joinUrl: `${base.origin}/c/${body.roomId}`, + websocketUrl: `${wsScheme}//${base.host}/ws/${body.roomId}`, + }; + + safeLog('room:created', { roomId: body.roomId, expiryDays }); + return Response.json(response, { status: 201 }); + } + + // --------------------------------------------------------------------------- + // Durable Object alarm — fires at `expiresAt`, purges the room. + // --------------------------------------------------------------------------- + + async alarm(): Promise { + // The alarm wakes the DO. We don't check expiresAt here — the alarm + // was scheduled specifically for now, so if there's any room in + // storage we purge it. purgeRoom is idempotent on absence. + await this.purgeRoom('expiry'); + } + + // --------------------------------------------------------------------------- + // WebSocket Upgrade + // --------------------------------------------------------------------------- + + private async handleWebSocketUpgrade(_request: Request): Promise { + const roomState = await this.ctx.storage.get('room'); + if (!roomState) { + return this.rejectUpgradeAsUnavailable(); + } + if (hasRoomExpired(roomState.expiresAt)) { + // Alarm should have fired; this is defense in depth. + await this.purgeRoom('upgrade-preempted-expired'); + return this.rejectUpgradeAsUnavailable(); + } + + // Per-room connection cap — see MAX_CONNECTIONS_PER_ROOM for rationale. + // Kept as HTTP 429 (not a WS close) because "full" is a transient, + // retryable condition worth signaling to any consumer — distinct from + // the permanent "room unavailable" UX state below. + if (this.ctx.getWebSockets().length >= MAX_CONNECTIONS_PER_ROOM) { + safeLog('ws:room-full', { roomId: roomState.roomId, cap: MAX_CONNECTIONS_PER_ROOM }); + return Response.json({ error: 'Room is full' }, { status: 429 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const expiresAt = Date.now() + CHALLENGE_TTL_MS; + // Server-assigned clientId — see WebSocketAttachment docstring. The auth + // proof is bound to this value, so a participant cannot choose an active + // peer's clientId at auth time. + const clientId = generateClientId(); + + this.ctx.acceptWebSocket(server); + + const attachment: WebSocketAttachment = { + authenticated: false, + roomId: roomState.roomId, + challengeId, + nonce, + expiresAt, + clientId, + }; + server.serializeAttachment(attachment); + + const challenge: AuthChallenge = { + type: 'auth.challenge', + challengeId, + nonce, + expiresAt, + clientId, + }; + server.send(JSON.stringify(challenge)); + + safeLog('ws:challenge-sent', { roomId: roomState.roomId, challengeId }); + return new Response(null, { status: 101, webSocket: client }); + } + + /** + * Complete the WebSocket upgrade and immediately close the client side + * with WS_CLOSE_ROOM_UNAVAILABLE. Used when the room is gone (never + * created, admin-deleted, or auto-expired). + * + * Why not return HTTP 404? Browsers don't expose the HTTP status of a + * failed WebSocket upgrade to page JS — a failed upgrade fires `close` + * with code 1006 and no reason, indistinguishable from a network drop. + * Accepting and immediately closing with our dedicated close code is + * the only way the client can route cold visitors to the dedicated + * RoomUnavailableScreen on the same code path as mid-session closes. + */ + private rejectUpgradeAsUnavailable(): Response { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + this.ctx.acceptWebSocket(server); + server.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_UNAVAILABLE); + return new Response(null, { status: 101, webSocket: client }); + } + + // --------------------------------------------------------------------------- + // WebSocket Message Handler (Hibernation API) + // --------------------------------------------------------------------------- + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + if (!meta) { + ws.close(WS_CLOSE_AUTH_REQUIRED, 'No connection state'); + return; + } + + let msg: Record; + try { + const raw = typeof message === 'string' ? message : new TextDecoder().decode(message); + msg = JSON.parse(raw); + } catch { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Invalid message format'); + return; + } + + // Pre-auth: only accept auth.response + if (!meta.authenticated) { + if (msg.type !== 'auth.response') { + ws.close(WS_CLOSE_AUTH_REQUIRED, 'Authentication required'); + return; + } + if ( + typeof msg.challengeId !== 'string' || !msg.challengeId || + typeof msg.clientId !== 'string' || !msg.clientId || + typeof msg.proof !== 'string' || !msg.proof + ) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Malformed auth response'); + return; + } + // Pre-auth length caps. Proofs + IDs are small in practice + // (challengeId ~22 chars, clientId server-assigned, proof 43 chars). + // Without caps, an unauthenticated peer can allocate/verify oversized + // strings. Match the admin-envelope caps for consistency. + if (msg.challengeId.length > AUTH_CHALLENGE_ID_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'challengeId too long'); + return; + } + if (msg.clientId.length > AUTH_CLIENT_ID_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'clientId too long'); + return; + } + if (msg.proof.length > AUTH_PROOF_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'proof too long'); + return; + } + // Validate lastSeq as non-negative integer if provided + let lastSeq: number | undefined; + if (msg.lastSeq !== undefined) { + if (typeof msg.lastSeq !== 'number' || !Number.isInteger(msg.lastSeq) || msg.lastSeq < 0) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'lastSeq must be a non-negative integer'); + return; + } + lastSeq = msg.lastSeq; + } + const authResponse: AuthResponse = { + type: 'auth.response', + challengeId: msg.challengeId as string, + clientId: msg.clientId as string, + proof: msg.proof as string, + lastSeq, + }; + await this.handleAuthResponse(ws, meta, authResponse); + return; + } + + // Post-auth: dispatch by message type + await this.handlePostAuthMessage(ws, meta, msg); + } + + // --------------------------------------------------------------------------- + // Post-Auth Message Dispatch + // --------------------------------------------------------------------------- + + private async handlePostAuthMessage( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + // Admin challenge request + if (msg.type === 'admin.challenge.request') { + await this.handleAdminChallengeRequest(ws, meta); + return; + } + + // Admin command + if (msg.type === 'admin.command') { + await this.handleAdminCommand(ws, meta, msg); + return; + } + + // ServerEnvelope — detect via channel field (no type field) + if (typeof msg.channel === 'string' && (msg.channel === 'event' || msg.channel === 'presence')) { + await this.handleServerEnvelope(ws, meta, msg); + return; + } + + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Unknown message type'); + } + + // --------------------------------------------------------------------------- + // Lifecycle Check (shared by event, presence, admin paths) + // --------------------------------------------------------------------------- + + /** + * Check room lifecycle state. Returns roomState if usable, or null if terminal. + * Closes the socket for rooms that are gone (purged) or past their deadline. + */ + private async checkRoomLifecycle( + ws: WebSocket, + _roomId: string, + ): Promise { + const roomState = await this.ctx.storage.get('room'); + if (!roomState) { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_UNAVAILABLE); + return null; + } + // Lazy-expiry backstop. Alarm handles the common case; this fires only + // if a socket somehow reached us after the deadline without the alarm + // having landed yet. + if (hasRoomExpired(roomState.expiresAt)) { + await this.purgeRoom('lifecycle-preempted-expired', ws); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_UNAVAILABLE); + return null; + } + return roomState; + } + + // --------------------------------------------------------------------------- + // Event Sequencing & Presence Relay + // --------------------------------------------------------------------------- + + private async handleServerEnvelope( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + const validated = validateServerEnvelope(msg); + if (isValidationError(validated)) { + this.sendError(ws, 'validation_error', validated.error); + return; + } + // isValidationError narrows; `validated` is ServerEnvelope here. + const envelope: ServerEnvelope = { + ...validated, + clientId: meta.clientId, // Override — prevent spoofing + }; + + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + if (envelope.channel === 'event') { + // Sequence the event on an IMMUTABLE next-state object. If the + // durable write fails, we must NOT have already bumped roomState.seq + // in memory — the next event must reuse the current seq, not a gap'd + // one. Nor may we broadcast an event that was never persisted. + const nextSeq = roomState.seq + 1; + const sequenced: SequencedEnvelope = { + seq: nextSeq, + receivedAt: Date.now(), + envelope, + }; + const nextRoomState: RoomDurableState = { ...roomState, seq: nextSeq }; + + // Atomic write: event key + room metadata in one put. + try { + await this.ctx.storage.put({ + [`event:${padSeq(nextSeq)}`]: sequenced, + 'room': nextRoomState, + } as Record); + } catch (e) { + // Persistence failed. Surface a clean error to the sender so their + // sendAnnotation* promise rejects (or their UI sees lastError) — + // otherwise they'd think the op landed on the wire. Do NOT bump + // in-memory seq, do NOT broadcast. + safeLog('room:event-persist-error', { + roomId: roomState.roomId, + attemptedSeq: nextSeq, + clientId: meta.clientId, + error: String(e), + }); + this.sendError(ws, 'event_persist_failed', 'Failed to persist event'); + return; + } + + // Durable write succeeded — commit in-memory state and broadcast. + Object.assign(roomState, nextRoomState); + const transport: RoomTransportMessage = { + type: 'room.event', + seq: sequenced.seq, + receivedAt: sequenced.receivedAt, + envelope: sequenced.envelope, + }; + this.broadcast(transport); + + safeLog('room:event-sequenced', { roomId: roomState.roomId, seq: roomState.seq, clientId: meta.clientId }); + } else { + // Presence — allowed in any non-terminal room state + const transport: RoomTransportMessage = { + type: 'room.presence', + envelope, + }; + this.broadcast(transport, ws); + } + } + + // --------------------------------------------------------------------------- + // Auth Response + Reconnect Replay + // --------------------------------------------------------------------------- + + private async handleAuthResponse( + ws: WebSocket, + meta: Extract, + authResponse: AuthResponse, + ): Promise { + if (authResponse.challengeId !== meta.challengeId) { + safeLog('ws:auth-rejected', { reason: 'unknown-challenge', roomId: meta.roomId }); + ws.close(WS_CLOSE_UNKNOWN_CHALLENGE, 'Unknown challenge'); + return; + } + + // The clientId in auth.response MUST match the server-assigned clientId + // from this connection's challenge. This prevents a participant from + // choosing another peer's clientId at auth time and overwriting their + // presence slot. + if (authResponse.clientId !== meta.clientId) { + safeLog('ws:auth-rejected', { reason: 'clientId-mismatch', roomId: meta.roomId }); + ws.close(WS_CLOSE_INVALID_PROOF, 'clientId does not match challenge'); + return; + } + + if (Date.now() > meta.expiresAt) { + safeLog('ws:auth-rejected', { reason: 'expired', roomId: meta.roomId }); + ws.close(WS_CLOSE_CHALLENGE_EXPIRED, 'Challenge expired'); + return; + } + + // Delegate lifecycle checks (deleted / expired / lazy-expiry) to the + // shared helper so this path doesn't drift from the post-auth path. + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + const valid = await verifyAuthProof( + roomState.roomVerifier, + meta.roomId, + authResponse.clientId, + meta.challengeId, + meta.nonce, + authResponse.proof, + ); + + if (!valid) { + safeLog('ws:auth-rejected', { reason: 'invalid-proof', roomId: meta.roomId }); + ws.close(WS_CLOSE_INVALID_PROOF, 'Invalid proof'); + return; + } + + // Auth successful — update attachment + const authenticatedMeta: WebSocketAttachment = { + authenticated: true, + roomId: meta.roomId, + clientId: authResponse.clientId, + authenticatedAt: Date.now(), + }; + ws.serializeAttachment(authenticatedMeta); + + // Send auth.accepted + const accepted: AuthAccepted = { + type: 'auth.accepted', + seq: roomState.seq, + snapshotSeq: roomState.snapshotSeq, + snapshotAvailable: !!roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(accepted)); + + // Reconnect replay + await this.replayEvents(ws, roomState, authResponse.lastSeq); + + safeLog('ws:authenticated', { roomId: meta.roomId, clientId: authResponse.clientId, lastSeq: authResponse.lastSeq }); + } + + private async replayEvents( + ws: WebSocket, + roomState: RoomDurableState, + lastSeq: number | undefined, + ): Promise { + // Local helper: single place that constructs and sends a room.snapshot + // transport message. Keeps the message shape in one place so any future + // field addition lands once. + const sendSnapshotToSocket = (): void => { + if (!roomState.snapshotCiphertext) return; + const snapshotMsg: RoomTransportMessage = { + type: 'room.snapshot', + snapshotSeq: roomState.snapshotSeq ?? 0, + snapshotCiphertext: roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(snapshotMsg)); + }; + + // Determine replay strategy + let sendSnapshot = false; + let replayFrom: number; + + if (lastSeq === undefined) { + // Fresh join — send snapshot + all events + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + } else if (lastSeq > roomState.seq) { + // Future claim — anomaly, fall back to snapshot + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + safeLog('ws:replay-anomaly', { roomId: roomState.roomId, lastSeq, currentSeq: roomState.seq }); + } else if (lastSeq === roomState.seq) { + // Fully caught up — still send snapshot if seq is 0 (fresh room, no events yet) + if (roomState.seq === 0) { + sendSnapshotToSocket(); + } + return; + } else { + // Check if we can replay incrementally + const nextNeededSeq = lastSeq + 1; + // In V1 earliestRetainedSeq stays 1 because there is no compaction. + // This branch becomes active once future compaction advances it. + if (nextNeededSeq < roomState.earliestRetainedSeq) { + // Too old — need snapshot fallback + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + } else { + // Can replay from retained log + replayFrom = nextNeededSeq; + } + } + + if (sendSnapshot) { + sendSnapshotToSocket(); + } + + // Replay events from storage (if any exist). Paginated so large rooms + // don't load the full event log into DO memory at reconnect time — + // storage.list() without a limit can blow memory in rooms with many + // retained events (V1 retains all events for the room lifetime). + if (roomState.seq > 0 && replayFrom <= roomState.seq) { + let cursor = `event:${padSeq(replayFrom)}`; + const end = `event:${padSeq(roomState.seq)}\uffff`; // inclusive of roomState.seq + while (true) { + const page = await this.ctx.storage.list({ + prefix: 'event:', + start: cursor, + end, + limit: REPLAY_PAGE_SIZE, + }); + if (page.size === 0) break; + let lastKey = cursor; + for (const [key, sequenced] of page) { + const transport: RoomTransportMessage = { + type: 'room.event', + seq: sequenced.seq, + receivedAt: sequenced.receivedAt, + envelope: sequenced.envelope, + }; + ws.send(JSON.stringify(transport)); + lastKey = key; + } + if (page.size < REPLAY_PAGE_SIZE) break; + // Advance cursor past the last emitted key. `storage.list({ start })` + // is INCLUSIVE, so passing `lastKey` would re-emit the final event. + // Appending U+0000 (the smallest Unicode code point) produces a string + // strictly greater than `lastKey` but strictly less than any valid + // next key — because padded numeric seq keys are ASCII digits only + // and never contain a null byte, no real key can fall between them. + // Using `\uffff` (max code point) here would be WRONG: it would skip + // all keys lexicographically between `lastKey` and `lastKey\uffff`, + // dropping legitimate events from the replay. + cursor = `${lastKey}\u0000`; + } + } + } + + // --------------------------------------------------------------------------- + // Admin Challenge-Response + // --------------------------------------------------------------------------- + + private async handleAdminChallengeRequest( + ws: WebSocket, + meta: Extract, + ): Promise { + // Lifecycle check — reject for terminal rooms + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const expiresAt = Date.now() + ADMIN_CHALLENGE_TTL_MS; + + // Store in attachment (survives hibernation) + const updatedMeta: WebSocketAttachment = { + ...meta, + pendingAdminChallenge: { challengeId, nonce, expiresAt }, + }; + ws.serializeAttachment(updatedMeta); + + const challenge: AdminChallenge = { + type: 'admin.challenge', + challengeId, + nonce, + expiresAt, + }; + ws.send(JSON.stringify(challenge)); + + safeLog('admin:challenge-sent', { roomId: meta.roomId, clientId: meta.clientId, challengeId }); + } + + private async handleAdminCommand( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + // ADMIN ERROR-CODE CONTRACT + // ------------------------- + // Every error code emitted from this method AND from helpers it calls + // (applyDelete, admin-scoped branches of handleAdminChallengeRequest) + // must be listed in the client's ADMIN_SCOPED_ERROR_CODES Set in + // packages/shared/collab/client-runtime/client.ts. That Set gates which + // room.error payloads reject a pending admin promise; a code that + // fires here but is missing from the Set leaves the client hanging + // until AdminTimeoutError. A code that fires on the event channel but + // is ADDED to the Set (e.g. validation_error) wrongly cancels + // unrelated in-flight admin commands. When adding/renaming/removing + // admin-path codes, update the client Set in the same change. + const validated = validateAdminCommandEnvelope(msg); + if (isValidationError(validated)) { + // Admin-scoped code so the client can distinguish admin-flow failures + // from event-channel failures (e.g. validation_error fires on the + // event channel while an admin command is in flight — rejecting + // pendingAdmin on those would be wrong). + this.sendAdminError(ws, AdminErrorCode.ValidationError, validated.error); + return; + } + // isValidationError narrows; `validated` is AdminCommandEnvelope here. + const cmdEnvelope = validated; + + // Reject cross-connection clientId spoofing + if (cmdEnvelope.clientId !== meta.clientId) { + this.sendAdminError(ws, AdminErrorCode.ClientIdMismatch, 'clientId does not match authenticated connection'); + return; + } + + // Check pending admin challenge + if (!meta.pendingAdminChallenge) { + this.sendAdminError(ws, AdminErrorCode.NoAdminChallenge, 'Request an admin challenge first'); + return; + } + if (cmdEnvelope.challengeId !== meta.pendingAdminChallenge.challengeId) { + this.sendAdminError(ws, AdminErrorCode.UnknownAdminChallenge, 'Challenge ID does not match'); + return; + } + + // Save challenge data before clearing + const { challengeId, nonce, expiresAt } = meta.pendingAdminChallenge; + + // Clear challenge from attachment (single-use) — serialize immediately + const { pendingAdminChallenge: _, ...cleanMeta } = meta; + ws.serializeAttachment(cleanMeta); + + // Check expiry + if (Date.now() > expiresAt) { + this.sendAdminError(ws, AdminErrorCode.AdminChallengeExpired, 'Admin challenge expired'); + return; + } + + // Lifecycle check — reject for terminal rooms + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + // Verify admin proof + const valid = await verifyAdminProof( + roomState.adminVerifier, + meta.roomId, + meta.clientId, + challengeId, + nonce, + cmdEnvelope.command, + cmdEnvelope.adminProof, + ); + + if (!valid) { + safeLog('admin:proof-rejected', { roomId: meta.roomId, clientId: meta.clientId }); + this.sendAdminError(ws, AdminErrorCode.InvalidAdminProof, 'Admin proof verification failed'); + return; + } + + // Apply command + switch (cmdEnvelope.command.type) { + case 'room.delete': + await this.applyDelete(ws, roomState); + break; + default: { + // Compile-time exhaustiveness guard: if a new admin command is added + // to the union and a case here is missed, TypeScript fails here. + const _exhaustive: never = cmdEnvelope.command.type; + void _exhaustive; + break; + } + } + } + + // --------------------------------------------------------------------------- + // Admin Command Execution + // --------------------------------------------------------------------------- + + private async applyDelete( + ws: WebSocket, + roomState: RoomDurableState, + ): Promise { + // checkRoomLifecycle ran at the top of handleAdminCommand, so this + // path is only reachable for a live room. purgeRoom wipes storage, + // purges event keys, cancels the expiry alarm, and closes every + // socket (including the admin's) with the generic unavailable + // reason — same terminal UX as an expired room or a never-created + // URL. + try { + await this.purgeRoom('admin'); + } catch (e) { + // purgeRoom already handles its own storage-error logging. Signal + // the admin caller that the delete didn't complete so their + // pending promise rejects cleanly. + safeLog('room:delete-error', { roomId: roomState.roomId, error: String(e) }); + this.sendAdminError(ws, AdminErrorCode.DeleteFailed, 'Failed to delete room'); + } + } + + // --------------------------------------------------------------------------- + // Storage Helpers + // --------------------------------------------------------------------------- + + /** + * Delete all event keys from storage in batches. Paginated for the same + * reason as replay: avoid loading the full event log into DO memory. + * Less latency-sensitive than replay but the memory bound still matters. + */ + private async purgeEventKeys(): Promise { + while (true) { + const page = await this.ctx.storage.list({ + prefix: 'event:', + limit: DELETE_BATCH_SIZE, + }); + if (page.size === 0) break; + await this.ctx.storage.delete([...page.keys()]); + if (page.size < DELETE_BATCH_SIZE) break; + } + } + + // --------------------------------------------------------------------------- + // Cleanup — single unified hard-delete path + // --------------------------------------------------------------------------- + + /** + * Hard-delete the room. No tombstone, no lingering state — once this + * returns, the DO storage is empty of room data and every connected + * socket has been closed with the generic "room unavailable" reason. + * + * Called from four triggers: + * - 'expiry' alarm fired at expiresAt + * - 'admin' creator clicked Delete room + * - 'create-preempted-expired' a fresh create is supplanting a + * room whose alarm never fired + * - 'lifecycle-preempted-expired' a socket reached us after the + * deadline; alarm hadn't landed yet + * - 'upgrade-preempted-expired' same, on the HTTP upgrade path + * + * `reason` is logged but not surfaced to clients — from their + * perspective, every purge looks the same: the link stops resolving. + */ + private async purgeRoom( + reason: 'expiry' | 'admin' | 'create-preempted-expired' | 'lifecycle-preempted-expired' | 'upgrade-preempted-expired', + except?: WebSocket, + ): Promise { + // Hard-delete the room record FIRST. Absence is what makes the room + // unreachable to any new connection — a concurrent WS upgrade or + // lifecycle check that lands mid-purge sees nothing and rejects. + // Closing sockets before this would leave a window where the room + // key still reads as present. + try { + await this.ctx.storage.delete('room'); + } catch (e) { + safeLog('room:purge-delete-error', { reason, error: String(e) }); + throw e; + } + + // Now close connected peers so they see the terminal close. + this.closeRoomSockets(WS_CLOSE_REASON_ROOM_UNAVAILABLE, except); + + // Best-effort: cancel the pending alarm in case the trigger wasn't + // the alarm itself. Avoids a redundant alarm wake after we've + // already emptied the room. + try { + await this.ctx.storage.deleteAlarm(); + } catch (e) { + safeLog('room:purge-delete-alarm-error', { reason, error: String(e) }); + } + + // Purge event log (per-event keys). + try { + await this.purgeEventKeys(); + } catch (e) { + safeLog('room:purge-event-keys-error', { reason, error: String(e) }); + } + + safeLog('room:purged', { reason }); + } + + // --------------------------------------------------------------------------- + // Broadcast Helpers + // --------------------------------------------------------------------------- + + /** + * Send a transport message to every authenticated socket in the room, + * optionally excluding one (e.g. the sender for presence relay). Send + * failures are intentionally ignored — the target socket may have closed. + */ + private broadcast(message: RoomTransportMessage, exclude?: WebSocket): void { + const json = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets()) { + if (socket === exclude) continue; + const att = socket.deserializeAttachment() as WebSocketAttachment | null; + if (att?.authenticated) { + try { socket.send(json); } catch { /* socket may have closed */ } + } + } + } + + private sendError(ws: WebSocket, code: string, message: string): void { + const error: RoomTransportMessage = { type: 'room.error', code, message }; + try { ws.send(JSON.stringify(error)); } catch { /* socket may have closed */ } + } + + /** + * Admin-scoped error emitter. Every admin-command rejection path + * (validate, challenge, proof, state, persist) MUST go through this + * wrapper instead of raw `sendError` so the `AdminErrorCode` type + * enforces the contract the client's rejection gate relies on + * (see `ADMIN_ERROR_CODES` in shared/collab/constants.ts). Adding a + * new admin error = add a key to `AdminErrorCode`, use it here; + * typos and non-admin codes surface as compile errors. + */ + private sendAdminError(ws: WebSocket, code: AdminErrorCode, message: string): void { + this.sendError(ws, code, message); + } + + private closeRoomSockets(reason: string, except?: WebSocket): void { + for (const socket of this.ctx.getWebSockets()) { + if (socket !== except) { + socket.close(WS_CLOSE_ROOM_UNAVAILABLE, reason); + } + } + } + + // --------------------------------------------------------------------------- + // WebSocket Lifecycle (Hibernation API) + // --------------------------------------------------------------------------- + + async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + const roomId = meta?.roomId ?? 'unknown'; + const clientId = meta?.authenticated ? meta.clientId : 'unauthenticated'; + safeLog('ws:closed', { roomId, clientId, code }); + + // Tell the remaining peers the closed client has left so they can + // drop that clientId's presence (cursor + avatar) immediately. + // Without this, peers wait out the 30s client-side TTL sweep, + // which made "refresh to test" pile up one ghost cursor per + // refresh until the entries expired. Only broadcast for + // authenticated sockets — unauth'd ones were never in peers' + // presence maps, so nothing needs cleanup. + // + // `exclude: ws` leaves the closing socket out of the fan-out. + // It may already be detached, but the broadcast's send-try/catch + // tolerates that either way. No payload beyond clientId — the + // protocol is zero-knowledge; we only relay opaque encrypted + // presence packets, and the clientId is server-assigned in the + // auth challenge so it's already non-secret. + if (meta?.authenticated) { + this.broadcast( + { type: 'room.participant.left', clientId: meta.clientId }, + ws, + ); + } + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + const roomId = meta?.roomId ?? 'unknown'; + safeLog('ws:error', { roomId, error: String(error) }); + } +} diff --git a/apps/room-service/core/room-engine.test.ts b/apps/room-service/core/room-engine.test.ts new file mode 100644 index 000000000..2af7fc763 --- /dev/null +++ b/apps/room-service/core/room-engine.test.ts @@ -0,0 +1,159 @@ +/** + * Slice 3 engine tests — validation, admin proofs, and lifecycle helpers. + * + * Tests act as external clients and import from @plannotator/shared/collab/client. + */ + +import { describe, expect, test } from 'bun:test'; +import { + validateServerEnvelope, + validateAdminCommandEnvelope, + isValidationError, +} from './validation'; +import type { ValidationError } from './validation'; +import { + deriveAdminKey, + computeAdminVerifier, + computeAdminProof, + verifyAdminProof, + generateChallengeId, + generateNonce, +} from '@plannotator/shared/collab/client'; +import type { AdminCommand } from '@plannotator/shared/collab'; + +// --------------------------------------------------------------------------- +// validateServerEnvelope +// --------------------------------------------------------------------------- + +describe('validateServerEnvelope', () => { + const validEvent = { + clientId: 'client-1', + opId: 'op-abc', + channel: 'event', + ciphertext: 'encrypted-data', + }; + + test('accepts valid event envelope', () => { + const result = validateServerEnvelope(validEvent); + expect(isValidationError(result)).toBe(false); + }); + + test('accepts valid presence envelope', () => { + const result = validateServerEnvelope({ ...validEvent, channel: 'presence' }); + expect(isValidationError(result)).toBe(false); + }); + + test('rejects missing clientId', () => { + const { clientId: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects missing opId', () => { + const { opId: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects invalid channel', () => { + expect(isValidationError(validateServerEnvelope({ ...validEvent, channel: 'invalid' }))).toBe(true); + }); + + test('rejects missing ciphertext', () => { + const { ciphertext: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects oversized event ciphertext (> 512 KB)', () => { + const result = validateServerEnvelope({ ...validEvent, ciphertext: 'x'.repeat(512_001) }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).status).toBe(413); + }); + + test('rejects oversized presence ciphertext (> 8 KB)', () => { + const result = validateServerEnvelope({ ...validEvent, channel: 'presence', ciphertext: 'x'.repeat(8_193) }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).status).toBe(413); + }); +}); + +// --------------------------------------------------------------------------- +// validateAdminCommandEnvelope +// --------------------------------------------------------------------------- + +describe('validateAdminCommandEnvelope', () => { + const validDelete = { + type: 'admin.command', + challengeId: 'ch_abc', + clientId: 'client-1', + command: { type: 'room.delete' }, + adminProof: 'proof-data', + }; + + test('accepts valid delete command', () => { + const result = validateAdminCommandEnvelope(validDelete); + expect(isValidationError(result)).toBe(false); + }); + + test('rejects unknown command type', () => { + expect(isValidationError(validateAdminCommandEnvelope({ ...validDelete, command: { type: 'room.explode' } }))).toBe(true); + }); + + test('rejects missing challengeId', () => { + const { challengeId: _, ...rest } = validDelete; + expect(isValidationError(validateAdminCommandEnvelope(rest))).toBe(true); + }); + + test('rejects missing adminProof', () => { + const { adminProof: _, ...rest } = validDelete; + expect(isValidationError(validateAdminCommandEnvelope(rest))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Admin Proof Round-Trip +// --------------------------------------------------------------------------- + +const ADMIN_SECRET = new Uint8Array(32); +ADMIN_SECRET.fill(0xcd); +const ROOM_ID = 'test-room-admin-proof'; + +describe('admin proof verification (end-to-end)', () => { + test('valid admin proof is accepted', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const command: AdminCommand = { type: 'room.delete' }; + + const proof = await computeAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command); + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command, proof); + expect(valid).toBe(true); + }); + + test('wrong proof is rejected', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const command: AdminCommand = { type: 'room.delete' }; + + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command, 'garbage-proof'); + expect(valid).toBe(false); + }); + + test('proof cannot verify against a different command shape (binding via canonicalJson)', async () => { + // V1 has a single AdminCommand shape, so we exercise the binding via + // an unsanctioned command — the proof must not verify for anything + // whose canonicalJson differs from what was signed. + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const deleteCommand: AdminCommand = { type: 'room.delete' }; + const otherCommand = { type: 'room.other' } as unknown as AdminCommand; + + const proof = await computeAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, deleteCommand); + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, otherCommand, proof); + expect(valid).toBe(false); + }); +}); diff --git a/apps/room-service/core/types.ts b/apps/room-service/core/types.ts new file mode 100644 index 000000000..f3d1bb3a2 --- /dev/null +++ b/apps/room-service/core/types.ts @@ -0,0 +1,73 @@ +/** + * Server-only types for the room-service Durable Object. + * + * RoomDurableState is the persistent room record stored in DO storage. + * WebSocketAttachment is serialized per-connection metadata that survives + * DO hibernation via serializeAttachment/deserializeAttachment. + */ + +// --------------------------------------------------------------------------- +// Worker Environment +// --------------------------------------------------------------------------- + +/** Cloudflare Worker environment bindings. */ +export interface Env { + ROOM: DurableObjectNamespace; + /** Wrangler-managed static asset binding. Serves `./public/index.html` (room shell) + hashed `./public/assets/*` chunks. Populated by `bun run build:shell`. */ + ASSETS?: { fetch(request: Request): Promise }; + ALLOWED_ORIGINS?: string; + ALLOW_LOCALHOST_ORIGINS?: string; + BASE_URL?: string; +} + +/** + * Durable state stored in DO storage under key 'room'. + * + * The room either exists (this record is present) or it doesn't (key + * absent). There's no "deleted" / "expired" tombstone state — purgeRoom + * hard-deletes the key when the 30-day alarm fires or when an admin + * issues delete. Absence means "link doesn't resolve." + * + * Events are NOT stored in this record — they use separate per-event keys + * ('event:0000000001', etc.) to stay within DO per-value size limits. + */ +export interface RoomDurableState { + /** Stored at creation — DO can't reverse idFromName(). */ + roomId: string; + roomVerifier: string; + adminVerifier: string; + seq: number; + /** Oldest event seq still in storage. Initialized to 1 at creation. */ + earliestRetainedSeq: number; + snapshotCiphertext?: string; + snapshotSeq?: number; + expiresAt: number | null; +} + +/** + * WebSocket attachment — survives hibernation via serializeAttachment/deserializeAttachment. + * + * Pre-auth: holds pending challenge state so the DO can verify after waking. + * Post-auth: holds authenticated connection metadata + optional pending admin challenge. + * Both variants carry roomId so webSocketMessage() can access it without a storage read. + */ +export type WebSocketAttachment = + | { + authenticated: false; + roomId: string; + challengeId: string; + nonce: string; + expiresAt: number; + /** Server-assigned ephemeral client id for this connection. Included in + * the auth challenge so the client's proof binds to it; prevents a + * malicious participant from choosing another user's clientId at auth + * time and overwriting their presence slot after auth. */ + clientId: string; + } + | { + authenticated: true; + roomId: string; + clientId: string; + authenticatedAt: number; + pendingAdminChallenge?: { challengeId: string; nonce: string; expiresAt: number }; + }; diff --git a/apps/room-service/core/validation.test.ts b/apps/room-service/core/validation.test.ts new file mode 100644 index 000000000..f7da2bf85 --- /dev/null +++ b/apps/room-service/core/validation.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from 'bun:test'; +import { + validateCreateRoomRequest, + isValidationError, + clampExpiryDays, + hasRoomExpired, + isRoomId, + validateServerEnvelope, + validateAdminCommandEnvelope, +} from './validation'; + +describe('validateCreateRoomRequest', () => { + // 22-char base64url room ID (matches generateRoomId() output: 16 random bytes) + const validRoomId = 'ABCDEFGHIJKLMNOPQRSTUv'; + // 43-char base64url verifiers (matches HMAC-SHA-256 output: 32 bytes) + const validVerifier = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq'; + const validAdminVerifier = 'abcdefghijklmnopqrstuvwxyz0123456789_-ABCDE'; + const validBody = { + roomId: validRoomId, + roomVerifier: validVerifier, + adminVerifier: validAdminVerifier, + initialSnapshotCiphertext: 'encrypted-snapshot-data', + }; + + test('accepts valid request', () => { + const result = validateCreateRoomRequest(validBody); + expect(isValidationError(result)).toBe(false); + if (!isValidationError(result)) { + expect(result.roomId).toBe(validRoomId); + expect(result.roomVerifier).toBe(validVerifier); + expect(result.adminVerifier).toBe(validAdminVerifier); + expect(result.initialSnapshotCiphertext).toBe('encrypted-snapshot-data'); + } + }); + + test('accepts request with expiresInDays', () => { + const result = validateCreateRoomRequest({ ...validBody, expiresInDays: 7 }); + expect(isValidationError(result)).toBe(false); + if (!isValidationError(result)) { + expect(result.expiresInDays).toBe(7); + } + }); + + test('rejects null body', () => { + const result = validateCreateRoomRequest(null); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.status).toBe(400); + } + }); + + test('rejects non-object body', () => { + const result = validateCreateRoomRequest('not an object'); + expect(isValidationError(result)).toBe(true); + }); + + test('rejects missing roomId', () => { + const { roomId: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('roomId'); + } + }); + + test('rejects empty roomId', () => { + const result = validateCreateRoomRequest({ ...validBody, roomId: '' }); + expect(isValidationError(result)).toBe(true); + }); + + test('rejects missing roomVerifier', () => { + const { roomVerifier: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('roomVerifier'); + } + }); + + test('rejects missing adminVerifier', () => { + const { adminVerifier: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('adminVerifier'); + } + }); + + test('rejects malformed roomVerifier (wrong length)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'too-short' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'a'.repeat(44) }))).toBe(true); + }); + + test('rejects malformed adminVerifier (wrong length)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, adminVerifier: 'too-short' }))).toBe(true); + }); + + test('rejects verifier with invalid characters (exactly 43 chars, bad final char)', () => { + // 26 + 16 + 1 = 43 chars, only the / is invalid + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop/' }))).toBe(true); + }); + + test('rejects missing initialSnapshotCiphertext', () => { + const { initialSnapshotCiphertext: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('initialSnapshotCiphertext'); + } + }); + + test('rejects oversized initialSnapshotCiphertext', () => { + const result = validateCreateRoomRequest({ + ...validBody, + initialSnapshotCiphertext: 'x'.repeat(1_500_001), + }); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.status).toBe(413); + } + }); + + test('rejects roomId with invalid characters (exactly 22 chars, bad final char)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU/' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU?' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU ' }))).toBe(true); + }); + + test('rejects roomId that is not exactly 22 chars', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTUvW' }))).toBe(true); // 23 chars + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTu' }))).toBe(true); // 21 chars + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'short' }))).toBe(true); + }); + + test('accepts exactly 22 base64url chars', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTUv' }))).toBe(false); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'abcdefghijklmnopqrstuv' }))).toBe(false); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: '0123456789_-ABCDEFGHIJ' }))).toBe(false); + }); +}); + +describe('clampExpiryDays', () => { + test('defaults to 30', () => { + expect(clampExpiryDays(undefined)).toBe(30); + }); + + test('0 means never (null)', () => { + expect(clampExpiryDays(0)).toBe(null); + }); + + test('clamps negative to 1', () => { + expect(clampExpiryDays(-5)).toBe(1); + }); + + test('clamps 100 to 30', () => { + expect(clampExpiryDays(100)).toBe(30); + }); + + test('passes through valid value', () => { + expect(clampExpiryDays(7)).toBe(7); + }); + + test('floors fractional days', () => { + expect(clampExpiryDays(7.9)).toBe(7); + }); +}); + +describe('hasRoomExpired', () => { + test('returns false before expiry', () => { + expect(hasRoomExpired(2_000, 1_999)).toBe(false); + }); + + test('returns false at exact expiry timestamp', () => { + expect(hasRoomExpired(2_000, 2_000)).toBe(false); + }); + + test('returns true after expiry', () => { + expect(hasRoomExpired(2_000, 2_001)).toBe(true); + }); + + test('returns false when expiresAt is null (never)', () => { + expect(hasRoomExpired(null)).toBe(false); + }); +}); + +describe('isRoomId', () => { + test('accepts valid 22-char base64url ids', () => { + expect(isRoomId('ABCDEFGHIJKLMNOPQRSTUv')).toBe(true); + expect(isRoomId('abcdef_ghij-klmnopqrst')).toBe(true); + }); + test('rejects wrong-length ids', () => { + expect(isRoomId('short')).toBe(false); + expect(isRoomId('A'.repeat(21))).toBe(false); + expect(isRoomId('A'.repeat(23))).toBe(false); + }); + test('rejects ids containing disallowed characters', () => { + expect(isRoomId('A'.repeat(21) + '!')).toBe(false); + expect(isRoomId('A'.repeat(21) + '/')).toBe(false); + expect(isRoomId('A'.repeat(21) + '=')).toBe(false); + }); + test('rejects non-string inputs', () => { + expect(isRoomId(undefined)).toBe(false); + expect(isRoomId(42 as unknown as string)).toBe(false); + expect(isRoomId(null)).toBe(false); + }); +}); + +describe('validateAdminCommandEnvelope — strips extra fields (P2)', () => { + const validBase = { + type: 'admin.command', + challengeId: 'cid', + clientId: 'client', + adminProof: 'proof', + }; + test('room.delete strips extras from command', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.delete', piggyback: 'value', extra: 'smuggled' }, + }); + expect(isValidationError(r)).toBe(false); + if (!isValidationError(r)) { + expect(r.command).toEqual({ type: 'room.delete' }); + expect(Object.keys(r.command)).toEqual(['type']); + } + }); + test('rejects unknown command type', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.explode' }, + }); + expect(isValidationError(r)).toBe(true); + }); + + test('rejects overlong adminProof', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + adminProof: 'x'.repeat(129), + command: { type: 'room.delete' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/adminProof/); + }); + + test('rejects overlong challengeId', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + challengeId: 'x'.repeat(65), + command: { type: 'room.delete' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/challengeId/); + }); + + test('rejects overlong clientId', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + clientId: 'x'.repeat(65), + command: { type: 'room.delete' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/clientId/); + }); +}); + +describe('validateServerEnvelope — length caps (P3)', () => { + const validBase = { + clientId: 'c123', + opId: 'o123', + channel: 'event' as const, + ciphertext: 'abc', + }; + test('accepts valid envelope', () => { + const r = validateServerEnvelope({ ...validBase }); + expect(isValidationError(r)).toBe(false); + }); + test('rejects opId over 64 chars (replay amplification surface)', () => { + const r = validateServerEnvelope({ ...validBase, opId: 'x'.repeat(65) }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/opId/); + }); + test('rejects clientId over 64 chars', () => { + const r = validateServerEnvelope({ ...validBase, clientId: 'x'.repeat(65) }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/clientId/); + }); +}); diff --git a/apps/room-service/core/validation.ts b/apps/room-service/core/validation.ts new file mode 100644 index 000000000..77415cd65 --- /dev/null +++ b/apps/room-service/core/validation.ts @@ -0,0 +1,234 @@ +/** + * Request body validation — pure functions, no Cloudflare APIs. + * Fully testable with bun:test. + */ + +import type { CreateRoomRequest, ServerEnvelope, AdminCommandEnvelope } from '@plannotator/shared/collab'; + +export interface ValidationError { + error: string; + status: number; +} + +const MIN_EXPIRY_DAYS = 1; +const MAX_EXPIRY_DAYS = 30; +const DEFAULT_EXPIRY_DAYS = 30; +const MAX_SNAPSHOT_CIPHERTEXT_LENGTH = 1_500_000; // ~1.5 MB +const MAX_EVENT_CIPHERTEXT_LENGTH = 512_000; // ~512 KB per event +const MAX_PRESENCE_CIPHERTEXT_LENGTH = 8_192; // ~8 KB per presence update + +/** Clamp expiry days to [1, 30], default 30. 0 means never. */ +export function clampExpiryDays(days: number | undefined): number | null { + if (days === undefined || days === null) return DEFAULT_EXPIRY_DAYS; + if (days === 0) return null; + return Math.max(MIN_EXPIRY_DAYS, Math.min(MAX_EXPIRY_DAYS, Math.floor(days))); +} + +/** True when a room is beyond its fixed retention deadline. Never-expiring rooms return false. */ +export function hasRoomExpired(expiresAt: number | null, now: number = Date.now()): boolean { + if (expiresAt === null) return false; + return now > expiresAt; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * Room IDs are generated from 16 random bytes and base64url-encoded without padding. + * That yields 22 URL-safe characters and 128 bits of entropy. + */ +const ROOM_ID_RE = /^[A-Za-z0-9_-]{22}$/; + +/** Runtime check for the roomId shape. Exported for use in WebSocket upgrade + * paths where invalid IDs must be rejected BEFORE idFromName/DO instantiation + * to avoid arbitrary DO names and storage reads on attacker-controlled input. */ +export function isRoomId(s: unknown): s is string { + return typeof s === 'string' && ROOM_ID_RE.test(s); +} + +/** + * HMAC-SHA-256 output is 32 bytes, which base64url-encodes to 43 chars without padding. + * Verifiers must match this exact shape. + */ +const VERIFIER_RE = /^[A-Za-z0-9_-]{43}$/; + +/** Validate a POST /api/rooms request body. */ +export function validateCreateRoomRequest( + body: unknown, +): CreateRoomRequest | ValidationError { + if (!body || typeof body !== 'object') { + return { error: 'Request body must be a JSON object', status: 400 }; + } + + const obj = body as Record; + + if (!isNonEmptyString(obj.roomId)) { + return { error: 'Missing or empty "roomId"', status: 400 }; + } + + if (!ROOM_ID_RE.test(obj.roomId)) { + return { error: '"roomId" must be exactly 22 base64url characters', status: 400 }; + } + + if (!isNonEmptyString(obj.roomVerifier) || !VERIFIER_RE.test(obj.roomVerifier)) { + return { error: '"roomVerifier" must be a 43-char base64url HMAC-SHA-256 verifier', status: 400 }; + } + + if (!isNonEmptyString(obj.adminVerifier) || !VERIFIER_RE.test(obj.adminVerifier)) { + return { error: '"adminVerifier" must be a 43-char base64url HMAC-SHA-256 verifier', status: 400 }; + } + + if (!isNonEmptyString(obj.initialSnapshotCiphertext)) { + return { error: 'Missing or empty "initialSnapshotCiphertext"', status: 400 }; + } + + if (obj.initialSnapshotCiphertext.length > MAX_SNAPSHOT_CIPHERTEXT_LENGTH) { + return { error: `"initialSnapshotCiphertext" exceeds max size (${Math.round(MAX_SNAPSHOT_CIPHERTEXT_LENGTH / 1024)} KB)`, status: 413 }; + } + + return { + roomId: obj.roomId, + roomVerifier: obj.roomVerifier, + adminVerifier: obj.adminVerifier, + initialSnapshotCiphertext: obj.initialSnapshotCiphertext, + expiresInDays: typeof obj.expiresInDays === 'number' ? obj.expiresInDays : undefined, + }; +} + +/** Type guard: is the result a ValidationError? Works with any validated union. */ +export function isValidationError(result: T | ValidationError): result is ValidationError { + return typeof result === 'object' && result !== null && 'error' in result; +} + +// --------------------------------------------------------------------------- +// Post-Auth Message Validation +// --------------------------------------------------------------------------- + +const VALID_CHANNELS = new Set(['event', 'presence']); +const VALID_ADMIN_COMMANDS = new Set(['room.delete']); + +/** + * Max opId length on inbound event-channel envelopes. opId is stored DURABLY + * inside sequenced envelopes, so an authenticated participant could otherwise + * bloat replay bandwidth/storage by sending oversized opIds. generateOpId() + * produces 22-char base64url values (128 bits); 64 gives comfortable headroom + * without enabling amplification. + */ +const MAX_OP_ID_LENGTH = 64; +/** + * Max clientId length. Server overrides envelope.clientId with the + * authenticated meta.clientId before persistence, but we still cap inbound + * values to keep validation symmetric and avoid storing oversized strings + * if the override is ever removed. + */ +const MAX_CLIENT_ID_LENGTH = 64; + +/** + * Max adminProof length. HMAC-SHA-256 base64url-encodes to 43 chars; the + * generous cap guards against pathological input without rejecting any + * legitimate client. Prevents an authenticated peer from spamming + * oversized proof strings to blow up verification cost / log volume. + */ +const MAX_ADMIN_PROOF_LENGTH = 128; + +/** Max challengeId length. generateChallengeId() produces 16-byte base64url + * (22 chars); the cap leaves generous headroom without legitimizing abuse. */ +const MAX_CHALLENGE_ID_LENGTH = 64; + +/** Validate a ServerEnvelope from an authenticated WebSocket message. */ +export function validateServerEnvelope( + msg: Record, +): ServerEnvelope | ValidationError { + if (!isNonEmptyString(msg.clientId)) { + return { error: 'Missing or empty "clientId"', status: 400 }; + } + if (msg.clientId.length > MAX_CLIENT_ID_LENGTH) { + return { error: `"clientId" exceeds max length ${MAX_CLIENT_ID_LENGTH}`, status: 400 }; + } + if (!isNonEmptyString(msg.opId)) { + return { error: 'Missing or empty "opId"', status: 400 }; + } + if (msg.opId.length > MAX_OP_ID_LENGTH) { + return { error: `"opId" exceeds max length ${MAX_OP_ID_LENGTH}`, status: 400 }; + } + if (!isNonEmptyString(msg.channel) || !VALID_CHANNELS.has(msg.channel)) { + return { error: '"channel" must be "event" or "presence"', status: 400 }; + } + if (!isNonEmptyString(msg.ciphertext)) { + return { error: 'Missing or empty "ciphertext"', status: 400 }; + } + + const maxSize = msg.channel === 'presence' + ? MAX_PRESENCE_CIPHERTEXT_LENGTH + : MAX_EVENT_CIPHERTEXT_LENGTH; + if (msg.ciphertext.length > maxSize) { + return { error: `Ciphertext exceeds max size for ${msg.channel} (${Math.round(maxSize / 1024)} KB)`, status: 413 }; + } + + return { + clientId: msg.clientId, + opId: msg.opId, + channel: msg.channel as 'event' | 'presence', + ciphertext: msg.ciphertext, + }; +} + +/** Validate an AdminCommandEnvelope from an authenticated WebSocket message. */ +export function validateAdminCommandEnvelope( + msg: Record, +): AdminCommandEnvelope | ValidationError { + if (!isNonEmptyString(msg.challengeId)) { + return { error: 'Missing or empty "challengeId"', status: 400 }; + } + // Cap string inputs that flow into proof verification and command dispatch. + // Prevents an authenticated peer from spamming oversized identifiers that + // would otherwise hit canonicalJson / log volume on every admin attempt. + if (msg.challengeId.length > MAX_CHALLENGE_ID_LENGTH) { + return { error: `"challengeId" exceeds max length ${MAX_CHALLENGE_ID_LENGTH}`, status: 400 }; + } + if (!isNonEmptyString(msg.clientId)) { + return { error: 'Missing or empty "clientId"', status: 400 }; + } + if (msg.clientId.length > MAX_CLIENT_ID_LENGTH) { + return { error: `"clientId" exceeds max length ${MAX_CLIENT_ID_LENGTH}`, status: 400 }; + } + if (!isNonEmptyString(msg.adminProof)) { + return { error: 'Missing or empty "adminProof"', status: 400 }; + } + if (msg.adminProof.length > MAX_ADMIN_PROOF_LENGTH) { + return { error: `"adminProof" exceeds max length ${MAX_ADMIN_PROOF_LENGTH}`, status: 400 }; + } + + if (!msg.command || typeof msg.command !== 'object') { + return { error: 'Missing or invalid "command"', status: 400 }; + } + + const cmd = msg.command as Record; + if (!isNonEmptyString(cmd.type) || !VALID_ADMIN_COMMANDS.has(cmd.type)) { + return { error: `Unknown command type: ${String(cmd.type)}`, status: 400 }; + } + + // Build a SANITIZED command with exactly the expected fields. Extra fields + // on the inbound payload are dropped. This is defense-in-depth: + // - The admin proof is computed over canonicalJson(command), so if a client + // smuggles extra fields into the payload, their proof is bound to + // `canonicalJson(dirty)` while the server's re-verification will be + // computed over `canonicalJson(sanitized)` — proof verification fails. + // Honest clients serialize clean commands and their proofs verify. + // - Downstream code (logging, storage, proof recomputation) only ever sees + // the narrow shape its type says it does. + // The type gate above (VALID_ADMIN_COMMANDS.has(cmd.type)) already + // restricts cmd.type to the single valid value. If a future admin + // command is added, expand the Set AND split the sanitization below + // into per-type branches at the same time. + const sanitizedCommand: AdminCommandEnvelope['command'] = { type: 'room.delete' }; + + return { + type: 'admin.command', + challengeId: msg.challengeId, + clientId: msg.clientId, + command: sanitizedCommand, + adminProof: msg.adminProof, + }; +} diff --git a/apps/room-service/entry.tsx b/apps/room-service/entry.tsx new file mode 100644 index 000000000..55bc185bc --- /dev/null +++ b/apps/room-service/entry.tsx @@ -0,0 +1,64 @@ +/** + * Browser entry for room.plannotator.ai. + * + * Two surfaces share this bundle: + * - `/` → LandingPage (upload a document, create a room) + * - `/c/:roomId` → AppRoot (room editor via useRoomMode) + * + * Both branches are lazy-loaded so neither pays for the other's code. + * Landing visitors (~10 KB) never download the editor bundle (~4 MB), + * and room visitors never download the landing page chunk. + * + * TanStack Router is intentionally deferred until room-service has 3-4+ + * real routes with data/loading needs. + */ + +import React, { lazy, Suspense } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; +// @ts-expect-error — Vite resolves CSS side-effect imports at build time; +// there is no .d.ts for the index.css file and adding one would not match +// the existing apps/hook pattern. TypeScript doesn't need to analyze it. +import '@plannotator/editor/styles'; + +const LandingPage = lazy(() => + import('@plannotator/ui/components/collab/LandingPage').then(m => ({ default: m.LandingPage })), +); + +const AppRoot = lazy(() => + import('@plannotator/editor').then(m => ({ default: m.default })), +); + +function RoomServiceEntry(): React.ReactElement { + const pathname = window.location.pathname; + + if (pathname === '/') { + return ( + + +

    Loading...

    +
+ }> + + + + ); + } + + return ( + + + + ); +} + +const root = document.getElementById('root'); +if (!root) { + throw new Error('Plannotator entry: #root element missing from index.html'); +} +createRoot(root).render( + + + , +); diff --git a/apps/room-service/index.html b/apps/room-service/index.html new file mode 100644 index 000000000..759a068d0 --- /dev/null +++ b/apps/room-service/index.html @@ -0,0 +1,24 @@ + + + + + + Plannotator + + + + + +
+ + + diff --git a/apps/room-service/package.json b/apps/room-service/package.json new file mode 100644 index 000000000..e2785e820 --- /dev/null +++ b/apps/room-service/package.json @@ -0,0 +1,29 @@ +{ + "name": "@plannotator/room-service", + "version": "0.1.0", + "private": true, + "scripts": { + "build:shell": "rm -rf public && vite build", + "dev": "bun run build:shell && wrangler dev", + "deploy": "bun run build:shell && wrangler deploy", + "test": "bun test" + }, + "dependencies": { + "@plannotator/editor": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "wrangler": "^4.80.0" + } +} diff --git a/apps/room-service/scripts/smoke.ts b/apps/room-service/scripts/smoke.ts new file mode 100644 index 000000000..e1569a3b3 --- /dev/null +++ b/apps/room-service/scripts/smoke.ts @@ -0,0 +1,330 @@ +/** + * Smoke test for room-service against a running wrangler dev instance. + * + * Usage: + * cd apps/room-service && wrangler dev # in one terminal + * bun run scripts/smoke.ts # in another terminal + * + * This acts as an external client: it imports from @plannotator/shared/collab/client + * to simulate browser/agent auth flows. Server runtime code must NOT do this. + * + * Exits 0 on success, non-zero on failure. + */ + +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, + computeAuthProof, + computeAdminProof, + encryptSnapshot, + encryptPayload, + encryptPresence, + generateRoomId, + generateRoomSecret, + generateAdminSecret, + generateOpId, +} from '@plannotator/shared/collab/client'; + +import type { + CreateRoomRequest, + CreateRoomResponse, + AdminChallenge, + AdminCommand, + RoomSnapshot, + RoomTransportMessage, +} from '@plannotator/shared/collab'; + +const BASE_URL = process.env.SMOKE_BASE_URL || 'http://localhost:8787'; +const WS_BASE = BASE_URL.replace(/^http/, 'ws'); + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, label: string): void { + if (condition) { + passed++; + console.log(` PASS: ${label}`); + } else { + failed++; + console.error(` FAIL: ${label}`); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Messages received on the socket — includes transport messages and admin challenges. */ +type SmokeMessage = RoomTransportMessage | AdminChallenge; + +interface AuthedSocket { + ws: WebSocket; + clientId: string; + messages: SmokeMessage[]; + closed: boolean; +} + +/** Connect, authenticate, and return a ready socket that collects messages. */ +async function connectAndAuth( + roomId: string, + roomVerifier: string, + lastSeq?: number, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${WS_BASE}/ws/${roomId}`); + // clientId is now assigned by the server in the auth.challenge message; + // we adopt it here instead of self-generating (see PresenceImpersonation + // fix). Placeholder until challenge arrives. + let clientId = ''; + const result: AuthedSocket = { ws, clientId: '', messages: [], closed: false }; + let authed = false; + + const timeout = setTimeout(() => { + if (!authed) { ws.close(); reject(new Error('Auth timeout')); } + }, 10_000); + + ws.onmessage = async (event) => { + const msg = JSON.parse(String(event.data)); + + if (!authed && msg.type === 'auth.challenge') { + clientId = msg.clientId; + result.clientId = clientId; + const proof = await computeAuthProof(roomVerifier, roomId, clientId, msg.challengeId, msg.nonce); + ws.send(JSON.stringify({ type: 'auth.response', challengeId: msg.challengeId, clientId, proof, lastSeq })); + return; + } + + if (!authed && msg.type === 'auth.accepted') { + authed = true; + clearTimeout(timeout); + // Collect subsequent messages + ws.onmessage = (e) => { + result.messages.push(JSON.parse(String(e.data))); + }; + resolve(result); + return; + } + }; + + ws.onclose = () => { result.closed = true; }; + ws.onerror = () => { if (!authed) reject(new Error('WebSocket error')); }; + }); +} + +/** Wait briefly for messages to arrive. */ +function wait(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function run(): Promise { + console.log(`\nSmoke testing room-service at ${BASE_URL}\n`); + + // ----------------------------------------------------------------------- + // 1. Health check + // ----------------------------------------------------------------------- + console.log('1. Health check'); + const healthRes = await fetch(`${BASE_URL}/health`); + assert(healthRes.ok, 'GET /health returns 200'); + + // ----------------------------------------------------------------------- + // 2. Create a room + // ----------------------------------------------------------------------- + console.log('\n2. Room creation'); + const roomId = generateRoomId(); + const roomSecret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const adminKey = await deriveAdminKey(adminSecret); + + const roomVerifier = await computeRoomVerifier(authKey, roomId); + const adminVerifier = await computeAdminVerifier(adminKey, roomId); + + const snapshot: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Smoke Test', annotations: [] }; + const snapshotCiphertext = await encryptSnapshot(eventKey, snapshot); + + const createBody: CreateRoomRequest = { + roomId, + roomVerifier, + adminVerifier, + initialSnapshotCiphertext: snapshotCiphertext, + }; + + const createRes = await fetch(`${BASE_URL}/api/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createBody), + }); + assert(createRes.status === 201, 'POST /api/rooms returns 201'); + + const createResponseBody = await createRes.json() as CreateRoomResponse; + assert(!createResponseBody.joinUrl.includes('#'), 'joinUrl has no fragment'); + + // ----------------------------------------------------------------------- + // 3. Duplicate room creation → 409 + // ----------------------------------------------------------------------- + console.log('\n3. Duplicate room creation'); + const dupRes = await fetch(`${BASE_URL}/api/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createBody), + }); + assert(dupRes.status === 409, 'Duplicate returns 409'); + + // ----------------------------------------------------------------------- + // 4. Fresh join receives snapshot + // ----------------------------------------------------------------------- + console.log('\n4. Fresh join receives snapshot'); + const client1 = await connectAndAuth(roomId, roomVerifier); + await wait(200); + const snapshots1 = client1.messages.filter(m => m.type === 'room.snapshot'); + assert(snapshots1.length === 1, 'Client1 received room.snapshot on join'); + + // ----------------------------------------------------------------------- + // 5. Two clients — event echo + broadcast + // ----------------------------------------------------------------------- + console.log('\n5. Event sequencing + echo'); + const client2 = await connectAndAuth(roomId, roomVerifier); + await wait(200); + // Clear join messages + client1.messages.length = 0; + client2.messages.length = 0; + + // Client1 sends an event. Use a real annotation — empty annotation.add is + // rejected by conforming clients (no-op would burn a durable seq). + const realAnnotation = { + id: 'smoke-ann-1', + blockId: 'block-1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT' as const, + originalText: 'hello', + createdA: Date.now(), + text: 'smoke test annotation', + }; + const eventCiphertext = await encryptPayload(eventKey, JSON.stringify({ type: 'annotation.add', annotations: [realAnnotation] })); + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'event', + ciphertext: eventCiphertext, + })); + await wait(500); + + const client1Events = client1.messages.filter(m => m.type === 'room.event'); + const client2Events = client2.messages.filter(m => m.type === 'room.event'); + assert(client1Events.length === 1, 'Sender receives echo (room.event)'); + assert(client2Events.length === 1, 'Other client receives room.event'); + + // ----------------------------------------------------------------------- + // 6. Presence relay — others only + // ----------------------------------------------------------------------- + console.log('\n6. Presence relay'); + client1.messages.length = 0; + client2.messages.length = 0; + + // Presence MUST be encrypted with presenceKey (not eventKey) and carry a + // valid PresenceState shape — conforming clients reject malformed presence. + const validPresence = { + user: { id: 'smoke-u1', name: 'smoke', color: '#f00' }, + cursor: null, + }; + const presenceCiphertext = await encryptPresence(presenceKey, validPresence); + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'presence', + ciphertext: presenceCiphertext, + })); + await wait(300); + + const client1Presence = client1.messages.filter(m => m.type === 'room.presence'); + const client2Presence = client2.messages.filter(m => m.type === 'room.presence'); + assert(client1Presence.length === 0, 'Sender does NOT receive own presence'); + assert(client2Presence.length === 1, 'Other client receives room.presence'); + + // ----------------------------------------------------------------------- + // 7. Reconnect replay + // ----------------------------------------------------------------------- + console.log('\n7. Reconnect replay'); + client2.ws.close(); + await wait(200); + + // Client1 sends another event while client2 is disconnected + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'event', + ciphertext: eventCiphertext, + })); + await wait(300); + + // Client2 reconnects with lastSeq from the first event (seq 1) + const client2b = await connectAndAuth(roomId, roomVerifier, 1); + await wait(500); + + const replayedEvents = client2b.messages.filter(m => m.type === 'room.event'); + assert(replayedEvents.length === 1, 'Reconnect replayed 1 missed event (seq 2)'); + if (replayedEvents.length > 0 && replayedEvents[0].type === 'room.event') { + assert(replayedEvents[0].seq === 2, 'Replayed event has seq 2'); + } + + // ----------------------------------------------------------------------- + // 8. Admin delete + // ----------------------------------------------------------------------- + console.log('\n8. Admin delete'); + client1.messages.length = 0; + + client1.ws.send(JSON.stringify({ type: 'admin.challenge.request' })); + await wait(300); + const deleteChallenge = client1.messages.find(m => m.type === 'admin.challenge') as AdminChallenge | undefined; + + if (deleteChallenge) { + const deleteCmd: AdminCommand = { type: 'room.delete' }; + const deleteProof = await computeAdminProof( + adminVerifier, roomId, client1.clientId, + deleteChallenge.challengeId, deleteChallenge.nonce, deleteCmd, + ); + client1.ws.send(JSON.stringify({ + type: 'admin.command', + challengeId: deleteChallenge.challengeId, + clientId: client1.clientId, + command: deleteCmd, + adminProof: deleteProof, + })); + await wait(500); + + assert(client1.closed, 'Client1 socket closed after delete'); + assert(client2b.closed, 'Client2 socket closed after delete'); + } + + // ----------------------------------------------------------------------- + // 9. Deleted room rejects new joins + // ----------------------------------------------------------------------- + console.log('\n9. Deleted room rejects new joins'); + try { + const client3 = await connectAndAuth(roomId, roomVerifier); + client3.ws.close(); + assert(false, 'Should not authenticate to deleted room'); + } catch { + assert(true, 'Deleted room rejects new WebSocket join'); + } + + // ----------------------------------------------------------------------- + // Summary + // ----------------------------------------------------------------------- + console.log(`\n${'='.repeat(40)}`); + console.log(`Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error('Smoke test failed:', err); + process.exit(1); +}); diff --git a/apps/room-service/static/Octicons-mark-github.svg b/apps/room-service/static/Octicons-mark-github.svg new file mode 100644 index 000000000..a8d117404 --- /dev/null +++ b/apps/room-service/static/Octicons-mark-github.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/room-service/static/banner_lite.webp b/apps/room-service/static/banner_lite.webp new file mode 100644 index 000000000..507746d8f Binary files /dev/null and b/apps/room-service/static/banner_lite.webp differ diff --git a/apps/room-service/static/demo-aiayn.md b/apps/room-service/static/demo-aiayn.md new file mode 100644 index 000000000..10129fd71 --- /dev/null +++ b/apps/room-service/static/demo-aiayn.md @@ -0,0 +1,371 @@ +Title: Attention Is All You Need + +URL Source: https://arxiv.org/html/1706.03762v7 + +Published Time: Fri, 06 Mar 2026 15:00:03 GMT + +Markdown Content: +Provided proper attribution is provided, Google hereby grants permission to reproduce the tables and figures in this paper solely for use in journalistic or scholarly works. + +Ashish Vaswani + +Google Brain + +avaswani@google.com + +&Noam Shazeer 1 1 footnotemark: 1 + +Google Brain + +noam@google.com + +&Niki Parmar 1 1 footnotemark: 1 + +Google Research + +nikip@google.com + +&Jakob Uszkoreit 1 1 footnotemark: 1 + +Google Research + +usz@google.com + +&Llion Jones 1 1 footnotemark: 1 + +Google Research + +llion@google.com + +&Aidan N. Gomez 1 1 footnotemark: 1 + +University of Toronto + +aidan@cs.toronto.edu&Łukasz Kaiser 1 1 footnotemark: 1 + +Google Brain + +lukaszkaiser@google.com + +&Illia Polosukhin 1 1 footnotemark: 1 + +illia.polosukhin@gmail.com + +Equal contribution. Listing order is random. Jakob proposed replacing RNNs with self-attention and started the effort to evaluate this idea. Ashish, with Illia, designed and implemented the first Transformer models and has been crucially involved in every aspect of this work. Noam proposed scaled dot-product attention, multi-head attention and the parameter-free position representation and became the other person involved in nearly every detail. Niki designed, implemented, tuned and evaluated countless model variants in our original codebase and tensor2tensor. Llion also experimented with novel model variants, was responsible for our initial codebase, and efficient inference and visualizations. Lukasz and Aidan spent countless long days designing various parts of and implementing tensor2tensor, replacing our earlier codebase, greatly improving results and massively accelerating our research. Work performed while at Google Brain.Work performed while at Google Research. + +###### Abstract + +The dominant sequence transduction models are based on complex recurrent or convolutional neural networks that include an encoder and a decoder. The best performing models also connect the encoder and decoder through an attention mechanism. We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely. Experiments on two machine translation tasks show these models to be superior in quality while being more parallelizable and requiring significantly less time to train. Our model achieves 28.4 BLEU on the WMT 2014 English-to-German translation task, improving over the existing best results, including ensembles, by over 2 BLEU. On the WMT 2014 English-to-French translation task, our model establishes a new single-model state-of-the-art BLEU score of 41.8 after training for 3.5 days on eight GPUs, a small fraction of the training costs of the best models from the literature. We show that the Transformer generalizes well to other tasks by applying it successfully to English constituency parsing both with large and limited training data. + +## 1 Introduction + +Recurrent neural networks, long short-term memory [[13](https://arxiv.org/html/1706.03762v7#bib.bib13)] and gated recurrent [[7](https://arxiv.org/html/1706.03762v7#bib.bib7)] neural networks in particular, have been firmly established as state of the art approaches in sequence modeling and transduction problems such as language modeling and machine translation [[35](https://arxiv.org/html/1706.03762v7#bib.bib35), [2](https://arxiv.org/html/1706.03762v7#bib.bib2), [5](https://arxiv.org/html/1706.03762v7#bib.bib5)]. Numerous efforts have since continued to push the boundaries of recurrent language models and encoder-decoder architectures [[38](https://arxiv.org/html/1706.03762v7#bib.bib38), [24](https://arxiv.org/html/1706.03762v7#bib.bib24), [15](https://arxiv.org/html/1706.03762v7#bib.bib15)]. + +Recurrent models typically factor computation along the symbol positions of the input and output sequences. Aligning the positions to steps in computation time, they generate a sequence of hidden states h_{t}, as a function of the previous hidden state h_{t-1} and the input for position t. This inherently sequential nature precludes parallelization within training examples, which becomes critical at longer sequence lengths, as memory constraints limit batching across examples. Recent work has achieved significant improvements in computational efficiency through factorization tricks [[21](https://arxiv.org/html/1706.03762v7#bib.bib21)] and conditional computation [[32](https://arxiv.org/html/1706.03762v7#bib.bib32)], while also improving model performance in case of the latter. The fundamental constraint of sequential computation, however, remains. + +Attention mechanisms have become an integral part of compelling sequence modeling and transduction models in various tasks, allowing modeling of dependencies without regard to their distance in the input or output sequences [[2](https://arxiv.org/html/1706.03762v7#bib.bib2), [19](https://arxiv.org/html/1706.03762v7#bib.bib19)]. In all but a few cases [[27](https://arxiv.org/html/1706.03762v7#bib.bib27)], however, such attention mechanisms are used in conjunction with a recurrent network. + +In this work we propose the Transformer, a model architecture eschewing recurrence and instead relying entirely on an attention mechanism to draw global dependencies between input and output. The Transformer allows for significantly more parallelization and can reach a new state of the art in translation quality after being trained for as little as twelve hours on eight P100 GPUs. + +## 2 Background + +The goal of reducing sequential computation also forms the foundation of the Extended Neural GPU [[16](https://arxiv.org/html/1706.03762v7#bib.bib16)], ByteNet [[18](https://arxiv.org/html/1706.03762v7#bib.bib18)] and ConvS2S [[9](https://arxiv.org/html/1706.03762v7#bib.bib9)], all of which use convolutional neural networks as basic building block, computing hidden representations in parallel for all input and output positions. In these models, the number of operations required to relate signals from two arbitrary input or output positions grows in the distance between positions, linearly for ConvS2S and logarithmically for ByteNet. This makes it more difficult to learn dependencies between distant positions [[12](https://arxiv.org/html/1706.03762v7#bib.bib12)]. In the Transformer this is reduced to a constant number of operations, albeit at the cost of reduced effective resolution due to averaging attention-weighted positions, an effect we counteract with Multi-Head Attention as described in section[3.2](https://arxiv.org/html/1706.03762v7#S3.SS2 "3.2 Attention ‣ 3 Model Architecture ‣ Attention Is All You Need"). + +Self-attention, sometimes called intra-attention is an attention mechanism relating different positions of a single sequence in order to compute a representation of the sequence. Self-attention has been used successfully in a variety of tasks including reading comprehension, abstractive summarization, textual entailment and learning task-independent sentence representations [[4](https://arxiv.org/html/1706.03762v7#bib.bib4), [27](https://arxiv.org/html/1706.03762v7#bib.bib27), [28](https://arxiv.org/html/1706.03762v7#bib.bib28), [22](https://arxiv.org/html/1706.03762v7#bib.bib22)]. + +End-to-end memory networks are based on a recurrent attention mechanism instead of sequence-aligned recurrence and have been shown to perform well on simple-language question answering and language modeling tasks [[34](https://arxiv.org/html/1706.03762v7#bib.bib34)]. + +To the best of our knowledge, however, the Transformer is the first transduction model relying entirely on self-attention to compute representations of its input and output without using sequence-aligned RNNs or convolution. In the following sections, we will describe the Transformer, motivate self-attention and discuss its advantages over models such as [[17](https://arxiv.org/html/1706.03762v7#bib.bib17), [18](https://arxiv.org/html/1706.03762v7#bib.bib18)] and [[9](https://arxiv.org/html/1706.03762v7#bib.bib9)]. + +## 3 Model Architecture + +![Image 1: Refer to caption](https://arxiv.org/html/1706.03762v7/Figures/ModalNet-21.png) + +Figure 1: The Transformer - model architecture. + +Most competitive neural sequence transduction models have an encoder-decoder structure [[5](https://arxiv.org/html/1706.03762v7#bib.bib5), [2](https://arxiv.org/html/1706.03762v7#bib.bib2), [35](https://arxiv.org/html/1706.03762v7#bib.bib35)]. Here, the encoder maps an input sequence of symbol representations (x_{1},...,x_{n}) to a sequence of continuous representations \mathbf{z}=(z_{1},...,z_{n}). Given \mathbf{z}, the decoder then generates an output sequence (y_{1},...,y_{m}) of symbols one element at a time. At each step the model is auto-regressive [[10](https://arxiv.org/html/1706.03762v7#bib.bib10)], consuming the previously generated symbols as additional input when generating the next. + +The Transformer follows this overall architecture using stacked self-attention and point-wise, fully connected layers for both the encoder and decoder, shown in the left and right halves of Figure[1](https://arxiv.org/html/1706.03762v7#S3.F1 "Figure 1 ‣ 3 Model Architecture ‣ Attention Is All You Need"), respectively. + +### 3.1 Encoder and Decoder Stacks + +##### Encoder: + +The encoder is composed of a stack of N=6 identical layers. Each layer has two sub-layers. The first is a multi-head self-attention mechanism, and the second is a simple, position-wise fully connected feed-forward network. We employ a residual connection [[11](https://arxiv.org/html/1706.03762v7#bib.bib11)] around each of the two sub-layers, followed by layer normalization [[1](https://arxiv.org/html/1706.03762v7#bib.bib1)]. That is, the output of each sub-layer is \mathrm{LayerNorm}(x+\mathrm{Sublayer}(x)), where \mathrm{Sublayer}(x) is the function implemented by the sub-layer itself. To facilitate these residual connections, all sub-layers in the model, as well as the embedding layers, produce outputs of dimension d_{\text{model}}=512. + +##### Decoder: + +The decoder is also composed of a stack of N=6 identical layers. In addition to the two sub-layers in each encoder layer, the decoder inserts a third sub-layer, which performs multi-head attention over the output of the encoder stack. Similar to the encoder, we employ residual connections around each of the sub-layers, followed by layer normalization. We also modify the self-attention sub-layer in the decoder stack to prevent positions from attending to subsequent positions. This masking, combined with fact that the output embeddings are offset by one position, ensures that the predictions for position i can depend only on the known outputs at positions less than i. + +### 3.2 Attention + +An attention function can be described as mapping a query and a set of key-value pairs to an output, where the query, keys, values, and output are all vectors. The output is computed as a weighted sum of the values, where the weight assigned to each value is computed by a compatibility function of the query with the corresponding key. + +#### 3.2.1 Scaled Dot-Product Attention + +We call our particular attention "Scaled Dot-Product Attention" (Figure[2](https://arxiv.org/html/1706.03762v7#S3.F2 "Figure 2 ‣ 3.2.2 Multi-Head Attention ‣ 3.2 Attention ‣ 3 Model Architecture ‣ Attention Is All You Need")). The input consists of queries and keys of dimension d_{k}, and values of dimension d_{v}. We compute the dot products of the query with all keys, divide each by \sqrt{d_{k}}, and apply a softmax function to obtain the weights on the values. + +In practice, we compute the attention function on a set of queries simultaneously, packed together into a matrix Q. The keys and values are also packed together into matrices K and V. We compute the matrix of outputs as: + +\mathrm{Attention}(Q,K,V)=\mathrm{softmax}(\frac{QK^{T}}{\sqrt{d_{k}}})V(1) + +The two most commonly used attention functions are additive attention [[2](https://arxiv.org/html/1706.03762v7#bib.bib2)], and dot-product (multiplicative) attention. Dot-product attention is identical to our algorithm, except for the scaling factor of \frac{1}{\sqrt{d_{k}}}. Additive attention computes the compatibility function using a feed-forward network with a single hidden layer. While the two are similar in theoretical complexity, dot-product attention is much faster and more space-efficient in practice, since it can be implemented using highly optimized matrix multiplication code. + +While for small values of d_{k} the two mechanisms perform similarly, additive attention outperforms dot product attention without scaling for larger values of d_{k}[[3](https://arxiv.org/html/1706.03762v7#bib.bib3)]. We suspect that for large values of d_{k}, the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients 1 1 1 To illustrate why the dot products get large, assume that the components of q and k are independent random variables with mean 0 and variance 1. Then their dot product, q\cdot k=\sum_{i=1}^{d_{k}}q_{i}k_{i}, has mean 0 and variance d_{k}.. To counteract this effect, we scale the dot products by \frac{1}{\sqrt{d_{k}}}. + +#### 3.2.2 Multi-Head Attention + +Scaled Dot-Product Attention + +![Image 2: Refer to caption](https://arxiv.org/html/1706.03762v7/Figures/ModalNet-19.png) + +Multi-Head Attention + +![Image 3: Refer to caption](https://arxiv.org/html/1706.03762v7/Figures/ModalNet-20.png) + +Figure 2: (left) Scaled Dot-Product Attention. (right) Multi-Head Attention consists of several attention layers running in parallel. + +Instead of performing a single attention function with d_{\text{model}}-dimensional keys, values and queries, we found it beneficial to linearly project the queries, keys and values h times with different, learned linear projections to d_{k}, d_{k} and d_{v} dimensions, respectively. On each of these projected versions of queries, keys and values we then perform the attention function in parallel, yielding d_{v}-dimensional output values. These are concatenated and once again projected, resulting in the final values, as depicted in Figure[2](https://arxiv.org/html/1706.03762v7#S3.F2 "Figure 2 ‣ 3.2.2 Multi-Head Attention ‣ 3.2 Attention ‣ 3 Model Architecture ‣ Attention Is All You Need"). + +Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions. With a single attention head, averaging inhibits this. + +\displaystyle\mathrm{MultiHead}(Q,K,V)\displaystyle=\mathrm{Concat}(\mathrm{head_{1}},...,\mathrm{head_{h}})W^{O} +\displaystyle\text{where}~\mathrm{head_{i}}\displaystyle=\mathrm{Attention}(QW^{Q}_{i},KW^{K}_{i},VW^{V}_{i}) + +Where the projections are parameter matrices W^{Q}_{i}\in\mathbb{R}^{d_{\text{model}}\times d_{k}}, W^{K}_{i}\in\mathbb{R}^{d_{\text{model}}\times d_{k}}, W^{V}_{i}\in\mathbb{R}^{d_{\text{model}}\times d_{v}} and W^{O}\in\mathbb{R}^{hd_{v}\times d_{\text{model}}}. + +In this work we employ h=8 parallel attention layers, or heads. For each of these we use d_{k}=d_{v}=d_{\text{model}}/h=64. Due to the reduced dimension of each head, the total computational cost is similar to that of single-head attention with full dimensionality. + +#### 3.2.3 Applications of Attention in our Model + +The Transformer uses multi-head attention in three different ways: + +* • +In "encoder-decoder attention" layers, the queries come from the previous decoder layer, and the memory keys and values come from the output of the encoder. This allows every position in the decoder to attend over all positions in the input sequence. This mimics the typical encoder-decoder attention mechanisms in sequence-to-sequence models such as [[38](https://arxiv.org/html/1706.03762v7#bib.bib38), [2](https://arxiv.org/html/1706.03762v7#bib.bib2), [9](https://arxiv.org/html/1706.03762v7#bib.bib9)]. + +* • +The encoder contains self-attention layers. In a self-attention layer all of the keys, values and queries come from the same place, in this case, the output of the previous layer in the encoder. Each position in the encoder can attend to all positions in the previous layer of the encoder. + +* • +Similarly, self-attention layers in the decoder allow each position in the decoder to attend to all positions in the decoder up to and including that position. We need to prevent leftward information flow in the decoder to preserve the auto-regressive property. We implement this inside of scaled dot-product attention by masking out (setting to -\infty) all values in the input of the softmax which correspond to illegal connections. See Figure[2](https://arxiv.org/html/1706.03762v7#S3.F2 "Figure 2 ‣ 3.2.2 Multi-Head Attention ‣ 3.2 Attention ‣ 3 Model Architecture ‣ Attention Is All You Need"). + +### 3.3 Position-wise Feed-Forward Networks + +In addition to attention sub-layers, each of the layers in our encoder and decoder contains a fully connected feed-forward network, which is applied to each position separately and identically. This consists of two linear transformations with a ReLU activation in between. + +\mathrm{FFN}(x)=\max(0,xW_{1}+b_{1})W_{2}+b_{2}(2) + +While the linear transformations are the same across different positions, they use different parameters from layer to layer. Another way of describing this is as two convolutions with kernel size 1. The dimensionality of input and output is d_{\text{model}}=512, and the inner-layer has dimensionality d_{ff}=2048. + +### 3.4 Embeddings and Softmax + +Similarly to other sequence transduction models, we use learned embeddings to convert the input tokens and output tokens to vectors of dimension d_{\text{model}}. We also use the usual learned linear transformation and softmax function to convert the decoder output to predicted next-token probabilities. In our model, we share the same weight matrix between the two embedding layers and the pre-softmax linear transformation, similar to [[30](https://arxiv.org/html/1706.03762v7#bib.bib30)]. In the embedding layers, we multiply those weights by \sqrt{d_{\text{model}}}. + +### 3.5 Positional Encoding + +Since our model contains no recurrence and no convolution, in order for the model to make use of the order of the sequence, we must inject some information about the relative or absolute position of the tokens in the sequence. To this end, we add "positional encodings" to the input embeddings at the bottoms of the encoder and decoder stacks. The positional encodings have the same dimension d_{\text{model}} as the embeddings, so that the two can be summed. There are many choices of positional encodings, learned and fixed [[9](https://arxiv.org/html/1706.03762v7#bib.bib9)]. + +In this work, we use sine and cosine functions of different frequencies: + +\displaystyle PE_{(pos,2i)}=sin(pos/10000^{2i/d_{\text{model}}}) +\displaystyle PE_{(pos,2i+1)}=cos(pos/10000^{2i/d_{\text{model}}}) + +where pos is the position and i is the dimension. That is, each dimension of the positional encoding corresponds to a sinusoid. The wavelengths form a geometric progression from 2\pi to 10000\cdot 2\pi. We chose this function because we hypothesized it would allow the model to easily learn to attend by relative positions, since for any fixed offset k, PE_{pos+k} can be represented as a linear function of PE_{pos}. + +We also experimented with using learned positional embeddings [[9](https://arxiv.org/html/1706.03762v7#bib.bib9)] instead, and found that the two versions produced nearly identical results (see Table[3](https://arxiv.org/html/1706.03762v7#S6.T3 "Table 3 ‣ 6.2 Model Variations ‣ 6 Results ‣ Attention Is All You Need") row (E)). We chose the sinusoidal version because it may allow the model to extrapolate to sequence lengths longer than the ones encountered during training. + +## 4 Why Self-Attention + +In this section we compare various aspects of self-attention layers to the recurrent and convolutional layers commonly used for mapping one variable-length sequence of symbol representations (x_{1},...,x_{n}) to another sequence of equal length (z_{1},...,z_{n}), with x_{i},z_{i}\in\mathbb{R}^{d}, such as a hidden layer in a typical sequence transduction encoder or decoder. Motivating our use of self-attention we consider three desiderata. + +One is the total computational complexity per layer. Another is the amount of computation that can be parallelized, as measured by the minimum number of sequential operations required. + +The third is the path length between long-range dependencies in the network. Learning long-range dependencies is a key challenge in many sequence transduction tasks. One key factor affecting the ability to learn such dependencies is the length of the paths forward and backward signals have to traverse in the network. The shorter these paths between any combination of positions in the input and output sequences, the easier it is to learn long-range dependencies [[12](https://arxiv.org/html/1706.03762v7#bib.bib12)]. Hence we also compare the maximum path length between any two input and output positions in networks composed of the different layer types. + +Table 1: Maximum path lengths, per-layer complexity and minimum number of sequential operations for different layer types. n is the sequence length, d is the representation dimension, k is the kernel size of convolutions and r the size of the neighborhood in restricted self-attention. + +As noted in Table [1](https://arxiv.org/html/1706.03762v7#S4.T1 "Table 1 ‣ 4 Why Self-Attention ‣ Attention Is All You Need"), a self-attention layer connects all positions with a constant number of sequentially executed operations, whereas a recurrent layer requires O(n) sequential operations. In terms of computational complexity, self-attention layers are faster than recurrent layers when the sequence length n is smaller than the representation dimensionality d, which is most often the case with sentence representations used by state-of-the-art models in machine translations, such as word-piece [[38](https://arxiv.org/html/1706.03762v7#bib.bib38)] and byte-pair [[31](https://arxiv.org/html/1706.03762v7#bib.bib31)] representations. To improve computational performance for tasks involving very long sequences, self-attention could be restricted to considering only a neighborhood of size r in the input sequence centered around the respective output position. This would increase the maximum path length to O(n/r). We plan to investigate this approach further in future work. + +A single convolutional layer with kernel width k