From 5631286e13fa9cd54a29064e55d6c56374f8a1de Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 08:37:58 -0700 Subject: [PATCH 01/83] Add agent collaborators design spec Co-Authored-By: Claude Fable 5 --- .../2026-08-30-agent-collaborators-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/plans/2026-08-30-agent-collaborators-design.md diff --git a/docs/plans/2026-08-30-agent-collaborators-design.md b/docs/plans/2026-08-30-agent-collaborators-design.md new file mode 100644 index 00000000..ca5360f5 --- /dev/null +++ b/docs/plans/2026-08-30-agent-collaborators-design.md @@ -0,0 +1,118 @@ +# Agent collaborators — design + +AI agents join vapor documents as collaborators that look and behave like people: they have a name, a colour, a cursor, they type at a human rhythm, they leave suggestions and comments. Vapor supplies the protocol and the presence; the intelligence lives in external MCP clients (Claude Code, claude.ai connectors, custom agents). + +Decisions made during brainstorming, 2026-08-30: + +- **Roles**: editor/reviewer, co-writer, and on-demand assistant are all in scope; the protocol is general enough for any agent behaviour ("open platform"). +- **Identity**: lightweight per-doc token roster. Tokens optionally carry an `owner` so a person's counterpart agent is attributable to them. Real auth comes later; the protocol doesn't change when it does. +- **Human-ness**: full simulation — presence, cursor movement, incremental typing, pauses. +- **Brains**: external only in this phase. Vapor makes no LLM calls. +- **Capability default**: new tokens get `suggest` + `comment`, not `write`. Direct writes are an explicit grant — the in-doc equivalent of the org's "agents open PRs, they don't push to main." +- **Build tooling**: unchanged from upstream mist (fork rule — see arfct/ops standards). + +## Architecture + +Three pieces, all in the existing worker: + +``` +MCP client (Claude Code, claude.ai, …) + │ streamable HTTP /mcp (Bearer token) + ▼ +VaporMcp (McpAgent Durable Object) ← tool schemas, token check, anchor resolution + │ DO-to-DO RPC + ▼ +DocumentAgent (existing DO, extended) ← roster, performance queue, events, Y.Doc + │ Yjs sync + awareness (unchanged) + ▼ +Human browsers +``` + +- **`VaporMcp`** (`agents/mcp.ts`) — an `McpAgent` from the Agents SDK, served at `/mcp` via `routeAgentRequest` in the worker entry. Stateless with respect to documents: every tool call names a `doc_id`, and `VaporMcp` calls that document's `DocumentAgent` stub. One MCP session can work across many documents. +- **`DocumentAgent`** (extended, not replaced) — gains three SQLite tables (`agent_tokens`, `performances`, `events`) and RPC methods the MCP layer calls. All Yjs mutation happens here, inside the DO that owns the doc. +- **Worker entry** (`workers/app.ts`) — adds `GET /:id.md` (raw markdown export) before React Router, alongside the existing agent routing. + +Client/server separation rule is unchanged: nothing in `app/` imports from `agents/`; shared types go in `app/shared/`. + +## Routing change + +Documents render at the root path: + +| Route | Handler | Notes | +|---|---|---| +| `/` | `home.tsx` | unchanged | +| `/new` | `new.ts` | unchanged | +| `/:id` | `docs.$id.tsx` (renamed pattern only) | was `/docs/:id` | +| `/:id.md` | worker entry, before React Router | raw markdown, CriticMarkup preserved | +| `/mcp` | `routeAgentRequest` → `VaporMcp` | streamable HTTP | +| `/agents/*` | `routeAgentRequest` → `DocumentAgent` | unchanged (Yjs WebSocket) | + +Root slugs are now a shared namespace. A reserved-word list (`new`, `mcp`, `agents`, `api`, `assets`, `demo`, `favicon.ico`, `robots.txt`, `.well-known`) lives in `app/shared/constants.ts`; the id generator rejects collisions and the `/:id` loader 404s reserved names defensively. + +## Tokens and the roster + +Each document keeps an `agent_tokens` table: `token_hash` (SHA-256), `name`, `color`, `owner` (nullable free-text for now; user id later), `capabilities` (subset of `read`, `comment`, `suggest`, `write`), `created_at`, `last_seen_at`. + +- **Minting**: an "Invite agent" action in the doc UI generates a token, stores its hash, and shows copy-paste MCP connection config (URL + bearer token). Agent names are slugs (`[a-z0-9-]{2,32}`, unique per doc) so `@name` mentions parse unambiguously; the UI shows a friendlier display form. Anyone who can open the doc can mint — the same trust model as the rest of vapor (public by URL). No MCP tool mints tokens; an agent cannot widen its own access. +- **Presentation**: `Authorization: Bearer ` on the MCP request. The token alone identifies doc-scoped permissions; tools still take `doc_id` because one token may later span docs — in this phase a token is valid only for the doc that minted it. +- **Capabilities**: `read` is implied for any valid token. `suggest` writes CriticMarkup marks; `write` edits directly; `comment` creates/replies to threads. Default grant: `suggest` + `comment`. + +## Tool surface + +All tools return structured content; markdown in, markdown out. `pace` is `natural` (default), `fast`, or `instant`. + +| Tool | Capability | Purpose | +|---|---|---| +| `read_document(doc_id)` | read | Markdown with per-block anchors, presence list, open threads | +| `insert(doc_id, anchor, where, markdown, pace?)` | write | Insert before/after a block, or append to doc | +| `replace(doc_id, from_anchor, to_anchor?, markdown, pace?)` | write | Replace a block range | +| `suggest(doc_id, anchor, find, replacement, note?)` | suggest | CriticMarkup addition/deletion marks on matched text | +| `comment(doc_id, anchor, quote, text)` | comment | Open a thread anchored to a highlight | +| `reply(doc_id, thread_id, text)` | comment | Reply in a thread | +| `join(doc_id, status?)` / `leave(doc_id)` | read | Enter/exit presence; status is a short activity string | +| `await_events(doc_id, since_cursor?, timeout_s?)` | read | Long-poll for mentions, thread replies, doc-changed digests | +| `create_document(markdown?)` | none — unauthenticated, like `/new` | New doc; returns id, URL, and a fresh default-capability token for it | + +Errors are typed: `stale_anchor` (includes a fresh snippet of the region so the agent can re-orient without a full re-read), `capability_denied`, `invalid_token`, `doc_not_found`, `doc_expired`, `find_not_matched`, `rate_limited`. + +## Anchors + +`read_document` returns blocks as `[b3 a91f] ## Heading text…` where `b3` is the block index and `a91f` is a short hash of the block's plain text. Edit tools resolve an anchor by hash first (index as a hint when the hash appears twice). If the hash no longer exists — a human edited that block since the read — the tool fails with `stale_anchor` rather than guessing. Anchors are computed on demand from the Y.Doc; nothing is stored. This is deliberately stateless; persistent block ids in the Yjs schema are a future upgrade if hash churn proves annoying in practice. + +## Performance engine (human-ness) + +Accepted mutations don't land atomically. `DocumentAgent` appends them to a `performances` queue and replays them: + +1. The agent's awareness state appears (name, colour, `isAgent: true`) if not already present. +2. Its cursor moves to the target position; brief pause (300–900 ms). +3. Text lands in small Yjs transactions — 2–6 characters per tick, 30–80 ms apart (roughly 60–120 wpm), with occasional longer pauses at sentence boundaries. Deletions sweep similarly. +4. Cursor rests at the end of the change; presence lingers until `leave` or an idle timeout (5 min → awareness removed, token stays valid). + +Scheduling uses the Agents SDK schedule/alarm machinery; while human connections exist the DO is active anyway. **If no humans are connected, performances apply instantly** — pacing is theatre for an audience, and skipping it saves duty cycles. `pace: "instant"` also bypasses the queue (bulk imports, counterpart syncs). Queued performances execute in order per agent; two agents can interleave. + +Rate limit: per token, a budget consistent with the simulated typing speed (enforced even at `instant` — 10 mutations/min, 20k chars/hour) so an agent can't be human-like on screen and a firehose in the CRDT. + +## Events and summoning + +`DocumentAgent` records events (`mention`, `thread_reply`, `doc_changed` digest) in an `events` table with a monotonic cursor, pruned with the doc. A mention is `@name` matching a roster agent's name, detected in inserted text. `await_events` long-polls up to ~50 s and returns anything after the caller's cursor; clients re-call in a loop to feel resident. This is what makes a counterpart agent summonable: its owner's client holds `await_events` open, someone types `@nicks-agent fix the intro`, the client wakes and edits. + +The UI renders agent presence with a distinguishing badge in the avatar stack and caret label — human-like, but never passing as human. + +## Other doors (this phase) + +- **Raw REST**: `GET /:id.md` public (docs are public by URL); mutation stays MCP-only this phase. The existing `curl /new -T file.md` flow is unchanged. +- **Claude connector story**: `/mcp` + bearer token works as a claude.ai custom connector and in Claude Code (`claude mcp add`), zero extra code — this is the acceptance demo. + +## Out of scope (recorded so they stay out) + +Hosted brains (vapor calling LLMs), real user auth, cross-doc tokens, GitHub/gist sync, Slack, agent-to-agent protocols, a headless Yjs client SDK, persistent block ids. + +## Testing + +- **Unit** (`tests/unit/`): anchor computation and resolution (incl. duplicate-hash and stale cases), reserved-slug enforcement, markdown export via the existing critic-serializer, performance chunking as a pure function (text → timed ticks), mention detection, capability checks. +- **Integration** (`tests/integration/`): MCP tool round-trips against a real `DocumentAgent` (mint token → read → suggest → marks present in Y.Doc; write without capability → `capability_denied`; concurrent human edit → `stale_anchor`), `/:id.md` export, event cursor semantics. +- Agent-package import constraints per CLAUDE.md: DO logic tested through integration tests; pure logic extracted to `app/shared/` or `app/lib/` where unit-testable. + +## Repo chores riding along (separate PRs, not this feature) + +CLAUDE.md restructured onto the arfct org template (keeping mist's repo-specific content); vapor row in ops `deployment.md`; domains recorded in `arfct/internal`. Build tooling (ESLint, CI workflow) intentionally unchanged — fork rule. From d1a65a3877295f1098701558bbcf4d50569d78aa Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 08:54:10 -0700 Subject: [PATCH 02/83] Add connect UI section to agent collaborators spec Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-30-agent-collaborators-design.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plans/2026-08-30-agent-collaborators-design.md b/docs/plans/2026-08-30-agent-collaborators-design.md index ca5360f5..7895e8af 100644 --- a/docs/plans/2026-08-30-agent-collaborators-design.md +++ b/docs/plans/2026-08-30-agent-collaborators-design.md @@ -98,6 +98,18 @@ Rate limit: per token, a budget consistent with the simulated typing speed (enfo The UI renders agent presence with a distinguishing badge in the avatar stack and caret label — human-like, but never passing as human. +## Connect UI + +Connecting an agent must be a copy-paste, not a documentation hunt. + +- **Invite agent** action in the doc's share/menu area opens a dialog: agent name (slug, auto-suggested), capability toggles (suggest + comment pre-checked; write off by default), optional owner. Creating it shows the token **once** (only the hash is stored) alongside ready-to-paste connection snippets: + - **Claude Code**: `claude mcp add --transport http vapor https://vapor.fyi/mcp --header "Authorization: Bearer "` + - **claude.ai**: the connector URL plus where to paste it (Settings → Connectors → Add custom connector). + - **Generic MCP client**: the `mcpServers` JSON block. + Each snippet has a copy button; the dialog warns the token can't be shown again (revoke and re-mint instead). +- **Roster panel** in the same dialog lists the doc's agents — name, colour, capability chips, owner, last seen — with revoke. +- **`GET /mcp` from a browser** (Accept: text/html) renders a short "how to connect" page instead of a protocol error, linking back to the invite flow. + ## Other doors (this phase) - **Raw REST**: `GET /:id.md` public (docs are public by URL); mutation stays MCP-only this phase. The existing `curl /new -T file.md` flow is unchanged. From c803d895e5b9680ae4a829b8c79e6080726c4ec7 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 08:56:15 -0700 Subject: [PATCH 03/83] Rename worker to vapor and route vapor.fyi as custom domain Co-Authored-By: Claude Fable 5 --- wrangler.jsonc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 777947e1..fa259046 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,10 @@ { "$schema": "node_modules/wrangler/config-schema.json", // Set CLOUDFLARE_ACCOUNT_ID env var or add your account_id here - "name": "mist", + "name": "vapor", + "routes": [ + { "pattern": "vapor.fyi", "custom_domain": true } + ], "compatibility_date": "2025-04-04", "compatibility_flags": ["nodejs_compat"], "main": "./workers/app.ts", From 3588d03cbf822c4acdfb69f21a985e954e0fa151 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:02:45 -0700 Subject: [PATCH 04/83] Add agent collaborators implementation plan Co-Authored-By: Claude Fable 5 --- .../2026-08-30-agent-collaborators-plan.md | 810 ++++++++++++++++++ 1 file changed, 810 insertions(+) create mode 100644 docs/plans/2026-08-30-agent-collaborators-plan.md diff --git a/docs/plans/2026-08-30-agent-collaborators-plan.md b/docs/plans/2026-08-30-agent-collaborators-plan.md new file mode 100644 index 00000000..2b121b26 --- /dev/null +++ b/docs/plans/2026-08-30-agent-collaborators-plan.md @@ -0,0 +1,810 @@ +# Agent Collaborators Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AI agents join vapor documents as human-like collaborators — presence, paced typing, suggestions, comments — driven by external MCP clients through a `/mcp` endpoint. + +**Architecture:** A `VaporMcp` (`McpAgent`) Durable Object serves MCP at `/mcp` and calls the existing `DocumentAgent` DO over RPC. `DocumentAgent` gains three SQLite tables (`agent_tokens`, `performances`, `events`), a performance engine that replays edits at typing speed, and synthesized awareness states so agents appear in the presence stack. Documents move to root-path URLs. Spec: `docs/plans/2026-08-30-agent-collaborators-design.md`. + +**Tech Stack:** Cloudflare Workers + Durable Objects, Agents SDK (`agents`, `agents/mcp`), `@modelcontextprotocol/sdk`, Yjs, y-protocols, React Router 7, TipTap 3, Vitest. + +## Global Constraints + +- Nothing under `app/` may import from `agents/` — shared code goes in `app/shared/` (types/constants) or `app/lib/` (logic). `agents/` MAY import from `app/lib/` and `app/shared/` (see `agents/document.ts`). +- The `agents` npm package uses `cloudflare:` imports and cannot load in plain Vitest — DO tests mock the `Agent` base class (pattern in `tests/integration/agents/document-agent.test.ts`). +- ESLint: unused variables prefixed `_`. Existing ESLint config stays — no Biome (fork rule). +- Node 22+. Tests mirror source structure under `tests/unit/` and `tests/integration/`. +- Commit subjects imperative, with trailer `Co-Authored-By: Claude Fable 5 `. +- Doc ids are 8 chars `[a-z0-9]` (`isValidDocumentId` in `app/shared/constants.ts`). +- Every RPC-facing error is a **return value** `{ error: { code, message, snippet? } }`, never a thrown exception (DO RPC serialization). +- Capability rules: `read` implied by any valid token; `suggest`, `comment`, `write` explicit. Default grant on mint: `["suggest", "comment"]`. +- Run `npm run typecheck && npm run lint && npm run test` before every commit. + +--- + +## Phase 1 — foundations (pure logic, no DO changes) + +### Task 1: Shared agent protocol module + +**Files:** +- Create: `app/shared/agent-protocol.ts` +- Test: `tests/unit/shared/agent-protocol.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (used by every later task): + +```ts +export type AgentCapability = "comment" | "suggest" | "write"; +export type Pace = "natural" | "fast" | "instant"; + +export interface AgentRosterEntry { + name: string; // slug, unique per doc + color: string; // one of USER_COLOURS .color values + owner: string | null; // free text this phase + capabilities: AgentCapability[]; + createdAt: number; + lastSeenAt: number | null; +} + +export interface BlockAnchor { index: number; hash: string; } // hash: 8 hex chars +export interface DocBlock extends BlockAnchor { text: string; } // text: markdown w/ critic delimiters + +export interface AgentError { code: AgentErrorCode; message: string; snippet?: string; } +export type AgentErrorCode = + | "stale_anchor" | "capability_denied" | "invalid_token" | "doc_not_found" + | "doc_expired" | "find_not_matched" | "rate_limited" | "invalid_name"; + +export const AGENT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; +export const RESERVED_SLUGS = ["new", "mcp", "agents", "api", "assets", "demo", "favicon.ico", "robots.txt"]; +export const DEFAULT_CAPABILITIES: AgentCapability[] = ["suggest", "comment"]; +export const RATE_LIMIT_MUTATIONS_PER_MIN = 10; +export const RATE_LIMIT_CHARS_PER_HOUR = 20_000; + +export function blockHash(text: string): string; // FNV-1a 32-bit, 8 hex chars +export function formatAnchor(a: BlockAnchor): string; // "b3-1a2b3c4d" +export function parseAnchor(s: string): BlockAnchor | null; +export function findMentions(text: string, rosterNames: string[]): string[]; +``` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unit/shared/agent-protocol.test.ts +import { describe, it, expect } from "vitest"; +import { + blockHash, formatAnchor, parseAnchor, findMentions, AGENT_NAME_RE, +} from "~/shared/agent-protocol"; + +describe("blockHash", () => { + it("is deterministic and 8 hex chars", () => { + expect(blockHash("## Heading")).toBe(blockHash("## Heading")); + expect(blockHash("## Heading")).toMatch(/^[0-9a-f]{8}$/); + expect(blockHash("a")).not.toBe(blockHash("b")); + }); +}); + +describe("anchor round-trip", () => { + it("formats and parses", () => { + const a = { index: 3, hash: "1a2b3c4d" }; + expect(formatAnchor(a)).toBe("b3-1a2b3c4d"); + expect(parseAnchor("b3-1a2b3c4d")).toEqual(a); + expect(parseAnchor("nonsense")).toBeNull(); + }); +}); + +describe("findMentions", () => { + it("matches roster names only, once each", () => { + expect(findMentions("hey @scribe and @scribe, not @ghost", ["scribe", "muse"])) + .toEqual(["scribe"]); + }); + it("requires word boundary", () => { + expect(findMentions("email me@scribe.com", ["scribe"])).toEqual([]); + }); +}); + +describe("AGENT_NAME_RE", () => { + it("accepts slugs, rejects others", () => { + expect(AGENT_NAME_RE.test("nicks-agent")).toBe(true); + expect(AGENT_NAME_RE.test("ab")).toBe(true); + expect(AGENT_NAME_RE.test("-bad")).toBe(false); + expect(AGENT_NAME_RE.test("Bad")).toBe(false); + expect(AGENT_NAME_RE.test("a".repeat(33))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/shared/agent-protocol.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```ts +// app/shared/agent-protocol.ts (types as in Interfaces block, plus:) +export function blockHash(text: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +export function formatAnchor(a: BlockAnchor): string { + return `b${a.index}-${a.hash}`; +} + +export function parseAnchor(s: string): BlockAnchor | null { + const m = /^b(\d+)-([0-9a-f]{8})$/.exec(s); + return m ? { index: Number(m[1]), hash: m[2] } : null; +} + +export function findMentions(text: string, rosterNames: string[]): string[] { + const found = new Set(); + for (const m of text.matchAll(/(?:^|[^a-z0-9@.])@([a-z0-9][a-z0-9-]{0,30}[a-z0-9])/g)) { + if (rosterNames.includes(m[1])) found.add(m[1]); + } + return [...found]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** — same command, expected PASS. +- [ ] **Step 5: Commit** — `git add app/shared/agent-protocol.ts tests/unit/shared/agent-protocol.test.ts && git commit -m "Add shared agent protocol module"` (with the Co-Authored-By trailer; all later commits too). + +### Task 2: Yjs ↔ markdown block layer + +**Files:** +- Create: `app/lib/y-markdown.ts` +- Test: `tests/unit/lib/y-markdown.test.ts` + +**Interfaces:** +- Consumes: `blockHash`, `DocBlock` from Task 1; `parseCriticMarkupToContent` from `app/lib/critic-parser.ts` (existing — read it first; it returns `{ cleanText, marks: { type, from, to, attrs? }[] }`). +- Produces: + +```ts +export function getBlocks(doc: Y.Doc): DocBlock[]; // one per paragraph element +export function yDocToMarkdown(doc: Y.Doc): string; // blocks joined with "\n" +export function resolveAnchor(doc: Y.Doc, anchor: string): + { index: number } | { error: "stale_anchor"; snippet: string }; // hash-first, index tiebreak +export function insertMarkdownBlocks(doc: Y.Doc, index: number, markdown: string): void; +export function deleteBlocks(doc: Y.Doc, from: number, to: number): void; +``` + +Document structure (see `agents/document.ts` `onRequest` POST): the fragment `doc.getXmlFragment("default")` is a flat list of `Y.XmlElement("paragraph")`, each containing one `Y.XmlText` whose string is a markdown source line, with critic marks as Yjs formatting attributes. Block text serialization re-inserts CriticMarkup delimiters around formatted runs — delimiters per mark type: `criticAddition` `{++ ++}`, `criticDeletion` `{-- --}`, `criticComment` `{>> <<}`, `criticHighlight` `{== ==}` (verify against `CRITIC_DELIMITERS` in `app/lib/critic-marks.ts:71` and reuse that export if it imports cleanly outside TipTap; otherwise define the map locally with a comment pointing there). + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unit/lib/y-markdown.test.ts +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteBlocks } from "~/lib/y-markdown"; +import { formatAnchor, blockHash } from "~/shared/agent-protocol"; + +function docFrom(lines: string[]): Y.Doc { + const doc = new Y.Doc(); + insertMarkdownBlocks(doc, 0, lines.join("\n")); + return doc; +} + +describe("y-markdown", () => { + it("round-trips plain markdown", () => { + const doc = docFrom(["# Title", "", "Body text."]); + expect(yDocToMarkdown(doc)).toBe("# Title\n\nBody text."); + expect(getBlocks(doc)).toHaveLength(3); + expect(getBlocks(doc)[0].hash).toBe(blockHash("# Title")); + }); + + it("round-trips CriticMarkup marks as delimiters", () => { + const doc = docFrom(["keep {--cut this--} and {++add this++} end"]); + expect(yDocToMarkdown(doc)).toBe("keep {--cut this--} and {++add this++} end"); + }); + + it("resolveAnchor finds by hash after blocks shift", () => { + const doc = docFrom(["alpha", "beta", "gamma"]); + const anchor = formatAnchor(getBlocks(doc)[2]); // gamma at index 2 + insertMarkdownBlocks(doc, 0, "zero"); // shifts everything down + const r = resolveAnchor(doc, anchor); + expect(r).toEqual({ index: 3 }); + }); + + it("resolveAnchor reports stale_anchor with a snippet", () => { + const doc = docFrom(["alpha", "beta"]); + const anchor = formatAnchor(getBlocks(doc)[1]); + deleteBlocks(doc, 1, 1); + const r = resolveAnchor(doc, anchor); + expect(r).toMatchObject({ error: "stale_anchor" }); + expect((r as { snippet: string }).snippet).toContain("alpha"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** — `npx vitest run tests/unit/lib/y-markdown.test.ts`, FAIL (module not found). + +- [ ] **Step 3: Write the implementation** + +```ts +// app/lib/y-markdown.ts +import * as Y from "yjs"; +import { blockHash, parseAnchor } from "~/shared/agent-protocol"; +import type { DocBlock } from "~/shared/agent-protocol"; +import { parseCriticMarkupToContent } from "~/lib/critic-parser"; + +const DELIMS: Record = { + criticAddition: ["{++", "++}"], + criticDeletion: ["{--", "--}"], + criticComment: ["{>>", "<<}"], + criticHighlight: ["{==", "==}"], +}; + +function blockText(el: Y.XmlElement): string { + let out = ""; + for (const child of el.toArray()) { + if (!(child instanceof Y.XmlText)) continue; + for (const op of child.toDelta() as { insert: string; attributes?: Record }[]) { + const markType = op.attributes && Object.keys(op.attributes).find((k) => DELIMS[k]); + out += markType ? DELIMS[markType][0] + op.insert + DELIMS[markType][1] : op.insert; + } + } + return out; +} + +export function getBlocks(doc: Y.Doc): DocBlock[] { + const frag = doc.getXmlFragment("default"); + return frag.toArray().map((el, index) => { + const text = el instanceof Y.XmlElement ? blockText(el) : ""; + return { index, hash: blockHash(text), text }; + }); +} + +export function yDocToMarkdown(doc: Y.Doc): string { + return getBlocks(doc).map((b) => b.text).join("\n"); +} + +export function resolveAnchor(doc: Y.Doc, anchor: string) { + const parsed = parseAnchor(anchor); + const blocks = getBlocks(doc); + const snippet = () => + blocks.slice(0, 6).map((b) => `[b${b.index} ${b.hash}] ${b.text.slice(0, 60)}`).join("\n"); + if (!parsed) return { error: "stale_anchor" as const, snippet: snippet() }; + const matches = blocks.filter((b) => b.hash === parsed.hash); + if (matches.length === 0) return { error: "stale_anchor" as const, snippet: snippet() }; + const best = matches.reduce((a, b) => + Math.abs(a.index - parsed.index) <= Math.abs(b.index - parsed.index) ? a : b); + return { index: best.index }; +} + +function makeParagraph(line: string): Y.XmlElement { + const { cleanText, marks } = parseCriticMarkupToContent(line); + const para = new Y.XmlElement("paragraph"); + const ytext = new Y.XmlText(cleanText); + for (const mark of marks) { + ytext.format(mark.from, mark.to - mark.from, { [mark.type]: mark.attrs ?? {} }); + } + para.insert(0, [ytext]); + return para; +} + +export function insertMarkdownBlocks(doc: Y.Doc, index: number, markdown: string): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => { + frag.insert(index, markdown.split("\n").map(makeParagraph)); + }); +} + +export function deleteBlocks(doc: Y.Doc, from: number, to: number): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => frag.delete(from, to - from + 1)); +} +``` + +- [ ] **Step 4: Run test to verify it passes.** Also run the full unit suite (`npx vitest run tests/unit`) to catch regressions. +- [ ] **Step 5: Commit** — `Add Yjs markdown block layer with content-hash anchors`. + +### Task 3: Documents render at the root path + +**Files:** +- Modify: `app/routes.ts`, `app/routes/home.tsx:43,59`, `app/routes/new.ts` (the `/docs/${id}` URL near the end) +- Rename: `app/routes/docs.$id.tsx` → `app/routes/doc.$id.tsx` (route id clarity; content unchanged except its own `Route` types import path) +- Test: `tests/unit/routes/root-path.test.ts` (plus update any existing tests referencing `/docs/`: `grep -rn "docs/" tests/`) + +**Interfaces:** +- Consumes: `isValidDocumentId` from `app/shared/constants.ts` (already 404-guards ids in the doc route loader — verify while editing). +- Produces: documents at `/:id`; `new.ts` returns `${origin}/${id}`. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unit/routes/root-path.test.ts +import { describe, it, expect } from "vitest"; +import routes from "~/routes"; + +describe("route table", () => { + it("serves documents at /:id, not /docs/:id", () => { + const flat = JSON.stringify(routes); + expect(flat).toContain('":id"'); + expect(flat).not.toContain("docs/:id"); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/unit/routes/root-path.test.ts`. +- [ ] **Step 3: Implement** — in `app/routes.ts`: `route(":id", "routes/doc.$id.tsx")` (static routes `/new` rank higher than the dynamic segment in React Router; keep index route first). Update the two `navigate(\`/docs/${id}\`)` calls in `home.tsx` to `navigate(\`/${id}\`)`; update `new.ts` response to `` `${url.origin}/${id}\n` ``. Rename the route file with `git mv`. +- [ ] **Step 4: Verify** — unit tests pass, then `npm run dev` and manually create a doc; the URL bar shows `/<8 chars>`. +- [ ] **Step 5: Commit** — `Serve documents at the root path`. + +--- + +## Phase 2 — DocumentAgent extensions + +All DO work is tested through the mock-Agent pattern in `tests/integration/agents/document-agent.test.ts`. **First step of Task 4 extends that mock's `sql` fake** with a generic in-memory table store for `agent_tokens`, `performances`, and `events` (match on table name in the query; support INSERT/SELECT/UPDATE/DELETE with the exact queries the implementation uses — keep the fake dumb and query-shaped, as the existing `doc_state` fake is). + +### Task 4: Token roster (mint, verify, revoke, list) + +**Files:** +- Create: `app/lib/agent-tokens.ts` +- Modify: `agents/document.ts` (add table + 4 RPC methods) +- Test: `tests/unit/lib/agent-tokens.test.ts`, extend `tests/integration/agents/document-agent.test.ts` + +**Interfaces:** +- Consumes: Task 1 types; `USER_COLOURS` from `app/shared/constants.ts`. +- Produces: + +```ts +// app/lib/agent-tokens.ts +export function generateAgentToken(): string; // "vpr_" + 43 base64url chars (32 random bytes) +export async function hashToken(token: string): Promise; // SHA-256 hex via crypto.subtle + +// agents/document.ts RPC methods (called on the stub from routes and VaporMcp) +async mintAgentToken(opts: { name: string; owner?: string; capabilities?: AgentCapability[] }): + Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }> +async getAgentRoster(): Promise +async revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }> +// internal, used by every agent RPC in later tasks: +private async verifyAgentToken(token: string, needs?: AgentCapability): + Promise<{ entry: AgentRosterEntry } | { error: AgentError }> +``` + +Table: `agent_tokens (token_hash TEXT PRIMARY KEY, name TEXT UNIQUE, color TEXT, owner TEXT, capabilities TEXT, created_at INTEGER, last_seen_at INTEGER)` — capabilities JSON-encoded. Mint validates `AGENT_NAME_RE`, rejects duplicate names (`invalid_name`), assigns the next `USER_COLOURS` entry round-robin by roster size. `verifyAgentToken` hashes the presented token, looks it up, checks the needed capability (`capability_denied`), updates `last_seen_at`, and returns `invalid_token` for misses. Existence check: reuse the `exists` row logic from `onRequest` GET — missing doc ⇒ `doc_not_found`. + +- [ ] **Step 1: Unit test for the pure helpers** + +```ts +// tests/unit/lib/agent-tokens.test.ts +import { describe, it, expect } from "vitest"; +import { generateAgentToken, hashToken } from "~/lib/agent-tokens"; + +describe("agent tokens", () => { + it("generates prefixed unique tokens", () => { + const t = generateAgentToken(); + expect(t).toMatch(/^vpr_[A-Za-z0-9_-]{43}$/); + expect(generateAgentToken()).not.toBe(t); + }); + it("hashes stably to 64 hex chars", async () => { + expect(await hashToken("vpr_x")).toBe(await hashToken("vpr_x")); + expect(await hashToken("vpr_x")).toMatch(/^[0-9a-f]{64}$/); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure**, then implement `agent-tokens.ts` (`crypto.getRandomValues`, base64url encode; `crypto.subtle.digest("SHA-256", …)` — both exist in Workers and in Node 22 Vitest). +- [ ] **Step 3: Integration test** — in the existing integration file, after extending the sql mock: + +```ts +describe("agent roster", () => { + it("mints, lists, verifies capability, revokes", async () => { + const agent = makeAgent(); // existing helper for the mocked DocumentAgent + await agent.onRequest(new Request("https://do/", { method: "POST" })); // create doc + const minted = await agent.mintAgentToken({ name: "scribe" }); + expect("token" in minted && minted.token).toMatch(/^vpr_/); + expect((await agent.getAgentRoster())[0]).toMatchObject({ + name: "scribe", capabilities: ["suggest", "comment"], + }); + // default grant lacks write (verifyAgentToken is private; cast for the test): + const v = await (agent as never as { verifyAgentToken(t: string, c?: string): Promise }) + .verifyAgentToken((minted as { token: string }).token, "write"); + expect(v).toMatchObject({ error: { code: "capability_denied" } }); + await agent.revokeAgentToken("scribe"); + expect(await agent.getAgentRoster()).toHaveLength(0); + }); + it("rejects bad names and duplicates", async () => { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { method: "POST" })); + expect(await agent.mintAgentToken({ name: "Bad Name" })).toMatchObject({ error: { code: "invalid_name" } }); + await agent.mintAgentToken({ name: "scribe" }); + expect(await agent.mintAgentToken({ name: "scribe" })).toMatchObject({ error: { code: "invalid_name" } }); + }); +}); +``` + +- [ ] **Step 4: Implement the DO methods**, run integration file until green. +- [ ] **Step 5: Commit** — `Add per-document agent token roster`. + +### Task 5: Read and instant mutations with anchors + +**Files:** +- Modify: `agents/document.ts` +- Test: extend `tests/integration/agents/document-agent.test.ts` + +**Interfaces:** +- Consumes: Task 2 (`getBlocks`, `yDocToMarkdown`, `resolveAnchor`, `insertMarkdownBlocks`, `deleteBlocks`), Task 4 (`verifyAgentToken`). +- Produces (RPC, all token-first; every mutation takes `pace?: Pace` which this task ignores — Task 6 wires it): + +```ts +async agentRead(token: string): Promise<{ + markdown: string; + blocks: { anchor: string; text: string }[]; // anchor = formatAnchor(block) + presence: { name: string; isAgent: boolean }[]; // humans from awareness + roster agents currently joined + threads: ThreadData[]; +} | { error: AgentError }> + +async agentInsert(token: string, args: { anchor?: string; where: "before" | "after" | "append"; markdown: string; pace?: Pace }): Promise<{ ok: true } | { error: AgentError }> +async agentReplace(token: string, args: { from: string; to?: string; markdown: string; pace?: Pace }): Promise<{ ok: true } | { error: AgentError }> +async agentSuggest(token: string, args: { anchor: string; find: string; replacement: string; pace?: Pace }): Promise<{ ok: true } | { error: AgentError }> +``` + +Semantics: +- `agentInsert` with `where: "append"` needs no anchor; otherwise resolve the anchor (`stale_anchor` on miss) and insert before/after that block index via `insertMarkdownBlocks`. +- `agentReplace` resolves `from` (and `to`, defaulting to `from`), calls `deleteBlocks`, then `insertMarkdownBlocks` at the from-index — inside one `doc.transact`. Requires `write`. +- `agentSuggest` requires `suggest`: resolve anchor, locate `find` in the block's `Y.XmlText` clean text (`indexOf`; `find_not_matched` with the block text as `snippet` when absent), then in one transaction `ytext.format(pos, find.length, { criticDeletion: {} })` and `ytext.insert(pos + find.length, replacement, { criticAddition: {} })`. Before implementing, read `app/lib/suggest-mode.ts` and mirror the attrs it puts on those marks (author metadata, if any) so agent suggestions render identically to human ones. +- Rate limiting on every mutation: keep `mutationLog: number[]` (timestamps) and `charLog: { at: number; chars: number }[]` per token in a `rate_limits` reuse of the events pattern — simplest correct version: two columns on `agent_tokens` (`recent_mutations TEXT`, JSON array of epoch-ms, pruned to the last hour on each check). Deny with `rate_limited` when >10 in the last 60s or >20 000 chars in the last hour (`RATE_LIMIT_*` constants from Task 1). + +- [ ] **Step 1: Write failing integration tests** + +```ts +describe("agent mutations", () => { + async function setup(caps?: AgentCapability[]) { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# Title\n\nBody." }), + })); + const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); + return { agent, token: (m as { token: string }).token }; + } + + it("reads markdown with anchors", async () => { + const { agent, token } = await setup(); + const r = await agent.agentRead(token); + expect("markdown" in r && r.markdown).toBe("# Title\n\nBody."); + expect("blocks" in r && r.blocks[0].anchor).toMatch(/^b0-[0-9a-f]{8}$/); + }); + + it("denies write without capability, allows with it", async () => { + const { agent, token } = await setup(); // default: no write + const denied = await agent.agentInsert(token, { where: "append", markdown: "More." }); + expect(denied).toMatchObject({ error: { code: "capability_denied" } }); + const { agent: a2, token: t2 } = await setup(["write"]); + await a2.agentInsert(t2, { where: "append", markdown: "More." }); + const r = await a2.agentRead(t2); + expect("markdown" in r && r.markdown).toContain("More."); + }); + + it("suggest lays critic marks", async () => { + const { agent, token } = await setup(); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[2].anchor; // "Body." + await agent.agentSuggest(token, { anchor, find: "Body.", replacement: "Better body." }); + const after = await agent.agentRead(token); + expect("markdown" in after && after.markdown).toContain("{--Body.--}{++Better body.++}"); + }); + + it("stale anchor errors after concurrent edit", async () => { + const { agent, token } = await setup(["write"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + await agent.agentReplace(token, { from: anchor, markdown: "# New title" }); + const stale = await agent.agentReplace(token, { from: anchor, markdown: "# Again" }); + expect(stale).toMatchObject({ error: { code: "stale_anchor" } }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement the four RPCs** in `agents/document.ts` (each starts with `verifyAgentToken(token, neededCap)`, then `ensureInitialised()`; mutations end by touching nothing else — Yjs `update` handler already persists). +- [ ] **Step 4: Run integration + full suite until green.** +- [ ] **Step 5: Commit** — `Add agent read and mutation RPCs with anchor checks`. + +### Task 6: Performance engine + +**Files:** +- Create: `app/lib/performance-chunks.ts` +- Modify: `agents/document.ts` +- Test: `tests/unit/lib/performance-chunks.test.ts`, extend integration file + +**Interfaces:** +- Consumes: Task 5 mutation internals (refactor each mutation's Yjs application into a private `applyMutation(m: PendingMutation)` so the queue and the instant path share it). +- Produces: + +```ts +// app/lib/performance-chunks.ts +export interface TypingTick { chunk: string; delayMs: number; } +export function chunkTyping(text: string, pace: "natural" | "fast", rng?: () => number): TypingTick[]; +// natural: 2–6 chars/tick, 30–80 ms; extra 300–900 ms pause after ".", "!", "?", "\n" +// fast: 8–16 chars/tick, 10–20 ms, no sentence pauses + +// agents/document.ts +private performanceQueue: PendingMutation[]; // also persisted to `performances` table on enqueue, deleted on completion +private hasHumanConnections(): boolean; // this.getConnections() non-empty +private async runPerformances(): Promise; // drains queue; setTimeout between ticks; instant when no humans +``` + +Behaviour: a mutation with `pace` `"natural"`/`"fast"` **enqueues** and returns `{ ok: true }` immediately; `"instant"` (or no human connections) applies synchronously. The runner takes one mutation at a time, moves the agent's cursor (Task 7 wires awareness; until then a no-op hook `onPerformanceCursor(name, blockIndex)`), and for insert/suggest text applies `chunkTyping` ticks as successive `ytext.insert` transactions so remote clients see typing. On `ensureInitialised`, any rows left in `performances` (eviction mid-performance) apply instantly. Anchor resolution happens at **dequeue** time, not enqueue, so queued work re-checks staleness; a stale queued mutation is dropped and recorded as an event (`doc_changed` digest payload `{"dropped": …}` — Task 8 adds the events table; until then just delete the row). + +- [ ] **Step 1: Unit-test the chunker** (deterministic rng: `() => 0.5`): + +```ts +import { describe, it, expect } from "vitest"; +import { chunkTyping } from "~/lib/performance-chunks"; + +describe("chunkTyping", () => { + it("covers the whole text in order", () => { + const ticks = chunkTyping("Hello world. Bye.", "natural", () => 0.5); + expect(ticks.map((t) => t.chunk).join("")).toBe("Hello world. Bye."); + }); + it("pauses after sentence ends", () => { + const ticks = chunkTyping("Hi. Yo", "natural", () => 0.5); + const afterDot = ticks.find((t) => t.chunk.startsWith(" Yo") || t.chunk.startsWith("Yo")); + expect(afterDot!.delayMs).toBeGreaterThanOrEqual(300); + }); + it("fast pace uses bigger chunks", () => { + expect(chunkTyping("x".repeat(100), "fast", () => 0.5).length) + .toBeLessThan(chunkTyping("x".repeat(100), "natural", () => 0.5).length); + }); +}); +``` + +- [ ] **Step 2: Run (fail), implement, run (pass).** +- [ ] **Step 3: Integration test with fake timers** — enqueue an insert at `natural` pace with one mock human connection attached; `vi.useFakeTimers()`; assert the doc is incomplete after the first tick and complete after `vi.runAllTimersAsync()`; assert instant application when `getConnections()` is empty. +- [ ] **Step 4: Full suite green.** +- [ ] **Step 5: Commit** — `Add performance engine for paced agent edits`. + +### Task 7: Agent presence in awareness + +**Files:** +- Create: `app/lib/agent-awareness.ts` +- Modify: `agents/document.ts` +- Test: `tests/unit/lib/agent-awareness.test.ts`, extend integration file + +**Interfaces:** +- Consumes: `MSG_AWARENESS` from `app/shared/constants.ts`; broadcast pattern from `agents/document.ts` `broadcastBinary`. +- Produces: + +```ts +// app/lib/agent-awareness.ts — hand-encode awareness updates for synthetic clients +export interface AgentPresenceState { + user: { name: string; color: string; isAgent: true }; + status?: string; + cursor?: unknown; // y-prosemirror relative-position JSON; see step 3 +} +export function encodeAgentAwareness( + clientId: number, clock: number, state: AgentPresenceState | null, +): Uint8Array; // full MSG_AWARENESS frame ready to broadcast: varUint(MSG_AWARENESS), varUint8Array(update) +// update format (y-protocols/awareness): varUint(1 entry), varUint(clientId), varUint(clock), varString(JSON state or "null") + +// agents/document.ts +private agentPresence: Map; // name → synthetic client +async agentJoin(token: string, status?: string): Promise<{ ok: true } | { error: AgentError }> +async agentLeave(token: string): Promise<{ ok: true } | { error: AgentError }> +``` + +Synthetic `clientId`: derive stably from the agent name (`parseInt(blockHash(name), 16) >>> 1`, forced non-zero) so reconnects reuse it. `agentJoin` broadcasts presence to every connection and replays current agent states in `onConnect` (after the existing awareness replay) so late joiners see resident agents. `onPerformanceCursor` from Task 6 becomes real: `Y.createRelativePositionFromTypeIndex(ytext, offset)` → `JSON.parse(JSON.stringify(Y.relativePositionToJSON(pos)))` placed in `state.cursor` as `{ anchor, head }` — **verify the exact field shape against what `@tiptap/extension-collaboration-caret` writes** by inspecting a live awareness state in the browser console before settling it (`provider.awareness.getStates()`), and match it. Idle timeout: on join, store `lastActiveAt`; a 5-minute `setTimeout` (reset on each performance) broadcasts a `null` state (presence removal). + +- [ ] **Step 1: Unit-test the encoder** — decode with the real `y-protocols/awareness` `applyAwarenessUpdate` against a scratch `Awareness` instance and assert the state landed: + +```ts +import * as Y from "yjs"; +import * as awarenessProtocol from "y-protocols/awareness"; +import * as decoding from "lib0/decoding"; +import { encodeAgentAwareness } from "~/lib/agent-awareness"; + +it("encodes a state the protocol can apply", () => { + const frame = encodeAgentAwareness(12345, 1, { user: { name: "scribe", color: "#4DD0E1", isAgent: true } }); + const dec = decoding.createDecoder(frame); + expect(decoding.readVarUint(dec)).toBe(1); // MSG_AWARENESS + const aw = new awarenessProtocol.Awareness(new Y.Doc()); + awarenessProtocol.applyAwarenessUpdate(aw, decoding.readVarUint8Array(dec), "test"); + expect(aw.getStates().get(12345)).toMatchObject({ user: { name: "scribe", isAgent: true } }); +}); +``` + +- [ ] **Step 2: Run (fail), implement encoder with `lib0/encoding`, run (pass).** +- [ ] **Step 3: Integration** — `agentJoin` then assert every mock connection received a frame whose decode contains the agent; connect a new mock client and assert `onConnect` replays it. +- [ ] **Step 4: UI check** — `npm run dev`, join an agent via a scratch script or temporary test route, confirm the presence stack shows the agent; style the `isAgent` badge in the avatar stack and caret label (find the presence component via `grep -rn "awareness" app/components app/lib/useYjsEditor.ts`; render a small "AI" chip using existing Tailwind patterns). +- [ ] **Step 5: Commit** — `Add synthetic agent presence to awareness`. + +### Task 8: Events, mentions, await_events + +**Files:** +- Modify: `agents/document.ts` +- Test: extend integration file (mention detection unit case is already covered by Task 1's `findMentions`) + +**Interfaces:** +- Consumes: `findMentions` (Task 1), roster (Task 4). +- Produces: + +```ts +// table: events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, payload TEXT, created_at INTEGER) +async agentAwaitEvents(token: string, args: { cursor?: number; timeoutMs?: number }): + Promise<{ events: { seq: number; type: "mention" | "thread_reply" | "doc_changed"; payload: unknown }[]; cursor: number } | { error: AgentError }> +private recordEvent(type: string, payload: unknown): void; // inserts row + resolves waiting promises +``` + +Mention detection: in `ensureInitialised`, after doc setup, attach `frag.observeDeep(events => …)` that walks each event's `changes.delta`, collects inserted strings, and for each `findMentions(text, rosterNames)` hit records `{ type: "mention", payload: { agent: name, text: } }`. Skip transactions originated by agent RPCs (tag them: `doc.transact(fn, "agent")` and check `event.transaction.origin !== "agent"`). `doc_changed` digests: on human-origin updates, record at most one event per 30 s (in-memory `lastDigestAt`). Long-poll: if no rows past `cursor`, park the resolver in `this.eventWaiters: (() => void)[]` and race a `setTimeout` of `min(timeoutMs ?? 50_000, 50_000)`; `recordEvent` flushes waiters. Events are pruned in the existing `alarm` (doc expiry) along with everything else. + +- [ ] **Step 1: Failing integration tests** — (a) mint `scribe`, simulate a human edit inserting `"ping @scribe please"` through the Yjs sync path (existing test helpers do real Y.Doc sync), then `agentAwaitEvents` returns the mention; (b) with no events, a call with `timeoutMs: 50` resolves empty after the timeout (fake timers); (c) `cursor` excludes already-seen events. +- [ ] **Step 2: Run (fail).** **Step 3: Implement.** **Step 4: Run (pass), full suite.** +- [ ] **Step 5: Commit** — `Add document events with mention detection and long-poll`. + +--- + +## Phase 3 — the MCP door + +### Task 9: VaporMcp server and worker routing + +**Files:** +- Create: `agents/mcp.ts`, `agents/mcp-tools.ts` +- Modify: `workers/app.ts`, `wrangler.jsonc`, `package.json` (add explicit deps: `@modelcontextprotocol/sdk`, `zod` — both already in the tree transitively; pin what `npm ls` shows) +- Test: `tests/unit/agents/mcp-tools.test.ts` (the tool→RPC mapping with a fake stub; `agents/mcp-tools.ts` must not import from the `agents` npm package so it stays unit-testable) + +**Interfaces:** +- Consumes: every `agent*` RPC from Tasks 4–8; `getAgentByName` (in `agents/mcp.ts` only). +- Produces: + +```ts +// agents/mcp-tools.ts — pure tool table, unit-testable +export interface DocStub { // the subset of DocumentAgent RPC the tools call + agentRead(token: string): Promise; + agentInsert(token: string, args: unknown): Promise; + agentReplace(token: string, args: unknown): Promise; + agentSuggest(token: string, args: unknown): Promise; + agentComment(token: string, args: unknown): Promise; + agentReply(token: string, args: unknown): Promise; + agentJoin(token: string, status?: string): Promise; + agentLeave(token: string): Promise; + agentAwaitEvents(token: string, args: unknown): Promise; +} +export interface ToolDeps { getStub(docId: string): Promise; token: string; } +export const TOOLS: { name: string; description: string; schema: ZodRawShape; + run(deps: ToolDeps, args: Record): Promise }[]; +// one entry per spec tool: read_document, insert, replace, suggest, comment, reply, +// join, leave, await_events (create_document is Task 10 — it's HTTP, not tool, per spec? NO: +// spec lists it as a tool with no auth; implement it in agents/mcp.ts directly since it needs env access +// and no token — see step 4.) +``` + +```ts +// agents/mcp.ts +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +export class VaporMcp extends McpAgent { + server = new McpServer({ name: "vapor", version: "1.0.0" }); + async init() { /* register TOOLS via this.server.tool(name, desc, schema, handler) */ } +} +``` + +Worker entry (`workers/app.ts`): before `routeAgentRequest`, + +```ts +if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + const auth = request.headers.get("Authorization"); + ctx.props = { bearer: auth?.startsWith("Bearer ") ? auth.slice(7) : null }; + return VaporMcp.serve("/mcp", { binding: "VaporMcp" }).fetch(request, env, ctx); +} +``` + +(Read the installed `agents/mcp` typings for the exact `serve` signature and props plumbing before writing this — `node_modules/agents/dist/mcp*.d.ts`. The pattern is the Cloudflare-documented `ctx.props` + `McpAgent.serve` one; adjust to the version in the lockfile, not from memory.) Tools resolve `deps.getStub(doc_id)` → `getAgentByName(this.env.DocumentAgent, docId)` and pass `this.props.bearer` as the token; a null bearer returns the `invalid_token` error object as tool content. Every tool returns `{ content: [{ type: "text", text: JSON.stringify(result) }] }`. + +`wrangler.jsonc`: add `{ "name": "VaporMcp", "class_name": "VaporMcp" }` to `durable_objects.bindings` and a migration `{ "tag": "v2", "new_sqlite_classes": ["VaporMcp"] }`; export `VaporMcp` from `workers/app.ts`. + +- [ ] **Step 1: Failing unit test for the tool table** + +```ts +// tests/unit/agents/mcp-tools.test.ts +import { describe, it, expect, vi } from "vitest"; +import { TOOLS } from "../../../agents/mcp-tools"; // no "~" — file lives outside app/ + +describe("mcp tool table", () => { + const names = TOOLS.map((t) => t.name); + it("exposes the spec surface", () => { + for (const n of ["read_document", "insert", "replace", "suggest", "comment", "reply", "join", "leave", "await_events"]) + expect(names).toContain(n); + }); + it("routes read_document to the stub with the bearer token", async () => { + const stub = { agentRead: vi.fn(async () => ({ markdown: "# Hi", blocks: [], presence: [], threads: [] })) }; + const tool = TOOLS.find((t) => t.name === "read_document")!; + const out = await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234" }, + ); + expect(stub.agentRead).toHaveBeenCalledWith("vpr_t"); + expect(out).toMatchObject({ markdown: "# Hi" }); + }); +}); +``` + +- [ ] **Step 2: Run (fail), implement `mcp-tools.ts` with zod shapes** (e.g. `insert`: `{ doc_id: z.string(), anchor: z.string().optional(), where: z.enum(["before","after","append"]), markdown: z.string(), pace: z.enum(["natural","fast","instant"]).optional() }`), run (pass). +- [ ] **Step 3: Implement `agents/mcp.ts` + worker routing + wrangler config**; `npm run typecheck` (regenerates Env types via cf-typegen). +- [ ] **Step 4: Add `create_document` tool inside `agents/mcp.ts`** — no token required: generate an id (`generateDocumentId`), POST to the doc stub as `app/routes/new.ts` does, mint a default token via `stub.mintAgentToken({ name: "agent" })`, return `{ id, url: "https://vapor.fyi/" + id, token }`. +- [ ] **Step 5: Live verification** — `npm run dev`, then from another terminal: `claude mcp add --transport http vapor-dev http://localhost:5173/mcp --header "Authorization: Bearer "`; in a Claude session, `read_document` a doc you created in the browser and `suggest` an edit; watch the marks land. Record the transcript command in the PR description. +- [ ] **Step 6: Commit** — `Serve MCP at /mcp backed by DocumentAgent RPCs`. + +### Task 10: Raw markdown export and /mcp help page + +**Files:** +- Modify: `workers/app.ts` +- Create: `app/lib/mcp-help.ts` (exports a `mcpHelpHtml(origin: string): string` template string) +- Test: `tests/unit/agents/worker-routes.test.ts` (extract the two handlers into `workers/routes.ts` as pure functions taking `(request, env)` so they unit-test without the worker harness; `workers/app.ts` calls them) + +**Interfaces:** +- Consumes: `yDocToMarkdown` via a new `DocumentAgent` RPC `exportMarkdown(): Promise<{ markdown: string } | { error: AgentError }>` (no token — docs are public by URL; add it to `agents/document.ts` in this task, exists-checked). +- Produces: `GET /:id.md` → `text/markdown` (404 for missing docs, id validated with `isValidDocumentId`); `GET /mcp` with `Accept: text/html` → the help page (API clients POST, so only browser GETs see it — check method GET + Accept header **before** the `VaporMcp.serve` branch). + +- [ ] **Step 1: Failing tests** — `handleRawMarkdown` returns 200 + `text/markdown` for an existing doc (fake env stub), 404 for missing/invalid id; `GET /mcp` with `Accept: text/html` returns HTML containing `claude mcp add`. +- [ ] **Step 2–4: Implement, run, full suite.** Help page copy (real content, sentence case): what vapor's MCP is, the three connection snippets from the spec's Connect UI section with the origin substituted, and a note that tokens are minted from a document's **Invite agent** dialog. +- [ ] **Step 5: Commit** — `Add raw markdown export and MCP help page`. + +--- + +## Phase 4 — connect UI + +### Task 11: Invite agent dialog and roster + +**Files:** +- Create: `app/components/InviteAgentDialog.tsx`, `app/routes/doc.$id.agents.ts` (resource route: `action` for mint/revoke, `loader` for roster) +- Modify: `app/routes.ts` (add `route(":id/agents", "routes/doc.$id.agents.ts")`), the doc header/menu component (find it: `grep -rn "Share\|menu\|header" app/components --include=*.tsx -l` and read `app/routes/doc.$id.tsx` for composition) +- Test: `tests/unit/routes/doc-agents-route.test.ts`, `tests/unit/components/InviteAgentDialog.test.tsx` + +**Interfaces:** +- Consumes: `mintAgentToken`, `getAgentRoster`, `revokeAgentToken` RPCs; `getCloudflare`/`getAgentByName` pattern from `app/routes/new.ts`. +- Produces: `POST /:id/agents` with JSON `{ intent: "mint", name, owner?, capabilities }` → `{ token, entry }` (token appears exactly once, in this response); `{ intent: "revoke", name }` → `{ ok: true }`; `GET /:id/agents` → `AgentRosterEntry[]`. + +Dialog (Radix is already a dependency — use `@radix-ui/react-dropdown-menu` peers' styling conventions from existing components): +1. Fields: name (text input, pre-filled with an unused slug like `scribe`, validated against `AGENT_NAME_RE` with inline error copy "Lowercase letters, digits, and hyphens"), owner (optional text), capability switches — **Suggest** and **Comment** on, **Write** off, using `@radix-ui/react-switch` like the existing theme controls. +2. On create: POST, then swap to the token screen — the token in a `` block with a copy button, the warning "This token is shown once. Revoke and re-mint to replace it.", and three copy-snippet rows (Claude Code command, claude.ai connector URL `https://vapor.fyi/mcp`, `mcpServers` JSON) built from `window.location.origin`. +3. Roster list below: name, colour dot, capability chips, owner, last seen (relative), revoke button per row. + +- [ ] **Step 1: Failing route test** — mock the stub (pattern from existing route tests in `tests/unit/routes/`): mint intent returns a token once; revoke removes; loader lists. +- [ ] **Step 2: Implement the resource route; run (pass).** +- [ ] **Step 3: Failing component test** (Testing Library, `tests/helpers/document-context.tsx` provides the doc context): renders defaults (suggest+comment checked, write unchecked); submitting calls fetch with the typed name; token screen shows the token from the mocked response. +- [ ] **Step 4: Implement the dialog, wire an "Invite agent" item into the doc menu, run tests.** +- [ ] **Step 5: Visual check** — `npm run dev`, mint a real token, connect Claude Code with the copied command, watch the agent appear in presence. This is the acceptance demo from the spec. +- [ ] **Step 6: Commit** — `Add invite agent dialog and roster management`. + +--- + +## Phase 5 — domains and docs + +### Task 12: Redirect secondary domains + +**Files:** +- Modify: `workers/routes.ts` (hostname redirect), `wrangler.jsonc` (two more custom domains) +- Test: extend `tests/unit/agents/worker-routes.test.ts` + +**Interfaces:** +- Produces: requests whose hostname is `vpr.fyi`, `www.vpr.fyi`, `vaporware.fyi`, `www.vaporware.fyi`, or `www.vapor.fyi` get `301` to `https://vapor.fyi` + original path/query. `wrangler.jsonc` routes gain `{ "pattern": "vpr.fyi", "custom_domain": true }` and `{ "pattern": "vaporware.fyi", "custom_domain": true }`. + +- [ ] **Step 1: Failing test** — `redirectHost(new Request("https://vpr.fyi/abc?x=1"))` returns 301 with `Location: https://vapor.fyi/abc?x=1`; `vapor.fyi` requests return `null`. +- [ ] **Step 2: Implement as the first check in the worker fetch; run (pass).** +- [ ] **Step 3: Deploy check** — after merge, `npm run deploy` (their zones may still hold Porkbun parking DNS records like vapor.fyi did; if wrangler errors with code 100117, delete the zone's A/CNAME parking records via the dashboard or API, then redeploy). `curl -sI https://vpr.fyi | grep -i location` shows `https://vapor.fyi/`. +- [ ] **Step 4: Commit** — `Redirect secondary domains to vapor.fyi`. + +### Task 13: Docs and org template + +**Files:** +- Modify: `README.md` (rename references mist→vapor where they describe *this* deployment, keep upstream credit: "vapor is a fork of [mist](https://github.com/inanimate-tech/mist)"; document the MCP door: connect command, tool list, token model), `CLAUDE.md` (restructure onto the arfct org template header — primer links — keeping every repo-specific section; add a short "Agent collaborators" architecture note pointing at the spec and the new modules) +- Test: none (docs) + +- [ ] **Step 1: Rewrite the two docs.** The CLAUDE.md template is `~/Code/artifact-process/ops/templates/CLAUDE.md` (also at github.com/arfct/ops → templates). +- [ ] **Step 2: `npm run lint` (markdown untouched by it, but keeps the habit), commit** — `Update README and CLAUDE.md for the vapor fork`. +- [ ] **Step 3: Open the PR** for the whole feature branch per org standards; PR body links the spec and lists the acceptance demo commands. After merge + deploy: add a vapor row to `arfct/ops` `primer/deployment.md` (separate ops PR) and record the three domains in `arfct/internal`. + +--- + +## Self-review notes + +- Spec coverage: routing (T3), tokens/roster (T4, T11), tool surface (T5, T8, T9), anchors (T2, T5), performance engine (T6), presence (T7), events/summoning (T8), connect UI (T11), `/mcp` help + `/:id.md` (T10), redirects (T12), chores (T13). `create_document` in T9 step 4. +- Deliberate deviations from spec text: none. Rate-limit storage rides on `agent_tokens` rather than its own table (fewer moving parts, same behaviour). +- Known verify-in-repo points (flagged inline): critic mark attrs (T5), collaboration-caret cursor field shape (T7), `McpAgent.serve` signature for the installed `agents` version (T9). Each has a concrete default plus the file to check. From 2d6a7fede6874a5bf9a12ddc252fb92c7b9f4bd7 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:03:01 -0700 Subject: [PATCH 05/83] Trigger CI Co-Authored-By: Claude Fable 5 From cdb4bdd4ba7c1ecfe940ba32c91629662bcd22d4 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:07:37 -0700 Subject: [PATCH 06/83] Add shared agent protocol module Co-Authored-By: Claude Fable 5 --- app/shared/agent-protocol.ts | 82 ++++++++++++++++++++++++ tests/unit/shared/agent-protocol.test.ts | 41 ++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 app/shared/agent-protocol.ts create mode 100644 tests/unit/shared/agent-protocol.test.ts diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts new file mode 100644 index 00000000..a5cb7a1a --- /dev/null +++ b/app/shared/agent-protocol.ts @@ -0,0 +1,82 @@ +export type AgentCapability = "comment" | "suggest" | "write"; +export type Pace = "natural" | "fast" | "instant"; + +export interface AgentRosterEntry { + name: string; // slug, unique per doc + color: string; // one of USER_COLOURS .color values + owner: string | null; // free text this phase + capabilities: AgentCapability[]; + createdAt: number; + lastSeenAt: number | null; +} + +export interface BlockAnchor { + index: number; + hash: string; // 8 hex chars +} + +export interface DocBlock extends BlockAnchor { + text: string; // markdown w/ critic delimiters +} + +export interface AgentError { + code: AgentErrorCode; + message: string; + snippet?: string; +} + +export type AgentErrorCode = + | "stale_anchor" + | "capability_denied" + | "invalid_token" + | "doc_not_found" + | "doc_expired" + | "find_not_matched" + | "rate_limited" + | "invalid_name"; + +export const AGENT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; +export const RESERVED_SLUGS = [ + "new", + "mcp", + "agents", + "api", + "assets", + "demo", + "favicon.ico", + "robots.txt", +]; +export const DEFAULT_CAPABILITIES: AgentCapability[] = ["suggest", "comment"]; +export const RATE_LIMIT_MUTATIONS_PER_MIN = 10; +export const RATE_LIMIT_CHARS_PER_HOUR = 20_000; + +export function blockHash(text: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +export function formatAnchor(a: BlockAnchor): string { + return `b${a.index}-${a.hash}`; +} + +export function parseAnchor(s: string): BlockAnchor | null { + const m = /^b(\d+)-([0-9a-f]{8})$/.exec(s); + return m ? { index: Number(m[1]), hash: m[2] } : null; +} + +export function findMentions( + text: string, + rosterNames: string[] +): string[] { + const found = new Set(); + for (const m of text.matchAll( + /(?:^|[^a-z0-9@.])@([a-z0-9][a-z0-9-]{0,30}[a-z0-9])/g + )) { + if (rosterNames.includes(m[1])) found.add(m[1]); + } + return [...found]; +} diff --git a/tests/unit/shared/agent-protocol.test.ts b/tests/unit/shared/agent-protocol.test.ts new file mode 100644 index 00000000..d8e16411 --- /dev/null +++ b/tests/unit/shared/agent-protocol.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { + blockHash, formatAnchor, parseAnchor, findMentions, AGENT_NAME_RE, +} from "~/shared/agent-protocol"; + +describe("blockHash", () => { + it("is deterministic and 8 hex chars", () => { + expect(blockHash("## Heading")).toBe(blockHash("## Heading")); + expect(blockHash("## Heading")).toMatch(/^[0-9a-f]{8}$/); + expect(blockHash("a")).not.toBe(blockHash("b")); + }); +}); + +describe("anchor round-trip", () => { + it("formats and parses", () => { + const a = { index: 3, hash: "1a2b3c4d" }; + expect(formatAnchor(a)).toBe("b3-1a2b3c4d"); + expect(parseAnchor("b3-1a2b3c4d")).toEqual(a); + expect(parseAnchor("nonsense")).toBeNull(); + }); +}); + +describe("findMentions", () => { + it("matches roster names only, once each", () => { + expect(findMentions("hey @scribe and @scribe, not @ghost", ["scribe", "muse"])) + .toEqual(["scribe"]); + }); + it("requires word boundary", () => { + expect(findMentions("email me@scribe.com", ["scribe"])).toEqual([]); + }); +}); + +describe("AGENT_NAME_RE", () => { + it("accepts slugs, rejects others", () => { + expect(AGENT_NAME_RE.test("nicks-agent")).toBe(true); + expect(AGENT_NAME_RE.test("ab")).toBe(true); + expect(AGENT_NAME_RE.test("-bad")).toBe(false); + expect(AGENT_NAME_RE.test("Bad")).toBe(false); + expect(AGENT_NAME_RE.test("a".repeat(33))).toBe(false); + }); +}); From 12efca417baa451c5c7999166f2455ab9da92b4a Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:10:56 -0700 Subject: [PATCH 07/83] Add Yjs markdown block layer with content-hash anchors Co-Authored-By: Claude Fable 5 --- app/lib/y-markdown.ts | 78 +++++++++++++++++++++++++++++++ tests/unit/lib/y-markdown.test.ts | 41 ++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 app/lib/y-markdown.ts create mode 100644 tests/unit/lib/y-markdown.test.ts diff --git a/app/lib/y-markdown.ts b/app/lib/y-markdown.ts new file mode 100644 index 00000000..7e0c3e02 --- /dev/null +++ b/app/lib/y-markdown.ts @@ -0,0 +1,78 @@ +import * as Y from "yjs"; +import { blockHash, parseAnchor } from "~/shared/agent-protocol"; +import type { DocBlock } from "~/shared/agent-protocol"; +import { parseCriticMarkupToContent } from "~/lib/critic-parser"; + +// Keep in sync with DELIMITERS in app/lib/critic-marks.ts:71. That module +// pulls in @tiptap/core and DOM APIs (document.createElement) via its +// ProseMirror decoration plugin, so it can't be imported from this +// TipTap-free layer — the map is small enough to duplicate here. +const DELIMS: Record = { + criticAddition: ["{++", "++}"], + criticDeletion: ["{--", "--}"], + criticComment: ["{>>", "<<}"], + criticHighlight: ["{==", "==}"], +}; + +function blockText(el: Y.XmlElement): string { + let out = ""; + for (const child of el.toArray()) { + if (!(child instanceof Y.XmlText)) continue; + for (const op of child.toDelta() as { insert: string; attributes?: Record }[]) { + const markType = op.attributes && Object.keys(op.attributes).find((k) => DELIMS[k]); + out += markType ? DELIMS[markType][0] + op.insert + DELIMS[markType][1] : op.insert; + } + } + return out; +} + +export function getBlocks(doc: Y.Doc): DocBlock[] { + const frag = doc.getXmlFragment("default"); + return frag.toArray().map((el, index) => { + const text = el instanceof Y.XmlElement ? blockText(el) : ""; + return { index, hash: blockHash(text), text }; + }); +} + +export function yDocToMarkdown(doc: Y.Doc): string { + return getBlocks(doc).map((b) => b.text).join("\n"); +} + +export function resolveAnchor( + doc: Y.Doc, + anchor: string, +): { index: number } | { error: "stale_anchor"; snippet: string } { + const parsed = parseAnchor(anchor); + const blocks = getBlocks(doc); + const snippet = () => + blocks.slice(0, 6).map((b) => `[b${b.index} ${b.hash}] ${b.text.slice(0, 60)}`).join("\n"); + if (!parsed) return { error: "stale_anchor" as const, snippet: snippet() }; + const matches = blocks.filter((b) => b.hash === parsed.hash); + if (matches.length === 0) return { error: "stale_anchor" as const, snippet: snippet() }; + const best = matches.reduce((a, b) => + Math.abs(a.index - parsed.index) <= Math.abs(b.index - parsed.index) ? a : b); + return { index: best.index }; +} + +function makeParagraph(line: string): Y.XmlElement { + const { cleanText, marks } = parseCriticMarkupToContent(line); + const para = new Y.XmlElement("paragraph"); + const ytext = new Y.XmlText(cleanText); + for (const mark of marks) { + ytext.format(mark.from, mark.to - mark.from, { [mark.type]: mark.attrs ?? {} }); + } + para.insert(0, [ytext]); + return para; +} + +export function insertMarkdownBlocks(doc: Y.Doc, index: number, markdown: string): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => { + frag.insert(index, markdown.split("\n").map(makeParagraph)); + }); +} + +export function deleteBlocks(doc: Y.Doc, from: number, to: number): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => frag.delete(from, to - from + 1)); +} diff --git a/tests/unit/lib/y-markdown.test.ts b/tests/unit/lib/y-markdown.test.ts new file mode 100644 index 00000000..0fd3f3e6 --- /dev/null +++ b/tests/unit/lib/y-markdown.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteBlocks } from "~/lib/y-markdown"; +import { formatAnchor, blockHash } from "~/shared/agent-protocol"; + +function docFrom(lines: string[]): Y.Doc { + const doc = new Y.Doc(); + insertMarkdownBlocks(doc, 0, lines.join("\n")); + return doc; +} + +describe("y-markdown", () => { + it("round-trips plain markdown", () => { + const doc = docFrom(["# Title", "", "Body text."]); + expect(yDocToMarkdown(doc)).toBe("# Title\n\nBody text."); + expect(getBlocks(doc)).toHaveLength(3); + expect(getBlocks(doc)[0].hash).toBe(blockHash("# Title")); + }); + + it("round-trips CriticMarkup marks as delimiters", () => { + const doc = docFrom(["keep {--cut this--} and {++add this++} end"]); + expect(yDocToMarkdown(doc)).toBe("keep {--cut this--} and {++add this++} end"); + }); + + it("resolveAnchor finds by hash after blocks shift", () => { + const doc = docFrom(["alpha", "beta", "gamma"]); + const anchor = formatAnchor(getBlocks(doc)[2]); // gamma at index 2 + insertMarkdownBlocks(doc, 0, "zero"); // shifts everything down + const r = resolveAnchor(doc, anchor); + expect(r).toEqual({ index: 3 }); + }); + + it("resolveAnchor reports stale_anchor with a snippet", () => { + const doc = docFrom(["alpha", "beta"]); + const anchor = formatAnchor(getBlocks(doc)[1]); + deleteBlocks(doc, 1, 1); + const r = resolveAnchor(doc, anchor); + expect(r).toMatchObject({ error: "stale_anchor" }); + expect((r as { snippet: string }).snippet).toContain("alpha"); + }); +}); From 1d9651192fa8eca396b38ffbb2c6b096a24d9b25 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:15:29 -0700 Subject: [PATCH 08/83] Reuse critic-constants delimiters and nest overlapping marks Address review: y-markdown's delimiter map now reuses the shared DELIMITERS export from critic-constants.ts instead of duplicating the strings, and blockText nests all active mark types on a run (highlight outermost) instead of rendering only the first one, so overlapping criticHighlight + criticAddition/Deletion/Comment marks no longer lose a delimiter pair silently. Co-Authored-By: Claude Fable 5 --- app/lib/y-markdown.ts | 34 +++++++++++++++++++++---------- tests/unit/lib/y-markdown.test.ts | 18 ++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/lib/y-markdown.ts b/app/lib/y-markdown.ts index 7e0c3e02..a837b382 100644 --- a/app/lib/y-markdown.ts +++ b/app/lib/y-markdown.ts @@ -2,25 +2,37 @@ import * as Y from "yjs"; import { blockHash, parseAnchor } from "~/shared/agent-protocol"; import type { DocBlock } from "~/shared/agent-protocol"; import { parseCriticMarkupToContent } from "~/lib/critic-parser"; +import { DELIMITERS } from "~/lib/critic-constants"; -// Keep in sync with DELIMITERS in app/lib/critic-marks.ts:71. That module -// pulls in @tiptap/core and DOM APIs (document.createElement) via its -// ProseMirror decoration plugin, so it can't be imported from this -// TipTap-free layer — the map is small enough to duplicate here. -const DELIMS: Record = { - criticAddition: ["{++", "++}"], - criticDeletion: ["{--", "--}"], - criticComment: ["{>>", "<<}"], - criticHighlight: ["{==", "==}"], +// Maps Yjs formatting-attribute keys (the ProseMirror mark names used by +// critic-marks.ts) to the shared delimiter strings in critic-constants.ts. +const MARK_DELIMS: Record = { + criticAddition: DELIMITERS.addition, + criticDeletion: DELIMITERS.deletion, + criticComment: DELIMITERS.comment, + criticHighlight: DELIMITERS.highlight, }; +// criticHighlight declares no `excludes` in critic-marks.ts, so a run can +// carry criticHighlight together with one of criticAddition/criticDeletion/ +// criticComment (those three do mutually exclude each other). When more +// than one mark type is present on a run, nest delimiters in this stable +// order — highlight outermost — rather than silently dropping all but one. +const NEST_ORDER = ["criticHighlight", "criticAddition", "criticDeletion", "criticComment"]; + function blockText(el: Y.XmlElement): string { let out = ""; for (const child of el.toArray()) { if (!(child instanceof Y.XmlText)) continue; for (const op of child.toDelta() as { insert: string; attributes?: Record }[]) { - const markType = op.attributes && Object.keys(op.attributes).find((k) => DELIMS[k]); - out += markType ? DELIMS[markType][0] + op.insert + DELIMS[markType][1] : op.insert; + const attrs = op.attributes ?? {}; + const activeTypes = NEST_ORDER.filter((t) => t in attrs); + let text = op.insert; + for (let i = activeTypes.length - 1; i >= 0; i--) { + const delims = MARK_DELIMS[activeTypes[i]]; + text = delims.open + text + delims.close; + } + out += text; } } return out; diff --git a/tests/unit/lib/y-markdown.test.ts b/tests/unit/lib/y-markdown.test.ts index 0fd3f3e6..712092d9 100644 --- a/tests/unit/lib/y-markdown.test.ts +++ b/tests/unit/lib/y-markdown.test.ts @@ -38,4 +38,22 @@ describe("y-markdown", () => { expect(r).toMatchObject({ error: "stale_anchor" }); expect((r as { snippet: string }).snippet).toContain("alpha"); }); + + it("round-trips overlapping highlight+addition marks on the same run without dropping either delimiter", () => { + const doc = new Y.Doc(); + const frag = doc.getXmlFragment("default"); + const para = new Y.XmlElement("paragraph"); + const ytext = new Y.XmlText("keep highlighted addition end"); + para.insert(0, [ytext]); + frag.insert(0, [para]); + + const start = "keep ".length; + const length = "highlighted addition".length; + // Apply both mark types to the same run in one format call, the way + // overlapping criticHighlight + criticAddition marks would land on the + // Yjs delta (criticHighlight declares no `excludes` in critic-marks.ts). + ytext.format(start, length, { criticHighlight: {}, criticAddition: {} }); + + expect(yDocToMarkdown(doc)).toBe("keep {=={++highlighted addition++}==} end"); + }); }); From 5fae152c138268190830b322da5083af75564aa8 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:18:28 -0700 Subject: [PATCH 09/83] Serve documents at the root path Co-Authored-By: Claude Fable 5 --- app/routes.ts | 2 +- app/routes/{docs.$id.tsx => doc.$id.tsx} | 2 +- app/routes/home.tsx | 4 ++-- app/routes/new.ts | 2 +- tests/unit/routes/new.test.ts | 6 +++--- tests/unit/routes/root-path.test.ts | 10 ++++++++++ 6 files changed, 18 insertions(+), 8 deletions(-) rename app/routes/{docs.$id.tsx => doc.$id.tsx} (99%) create mode 100644 tests/unit/routes/root-path.test.ts diff --git a/app/routes.ts b/app/routes.ts index 73a137db..1b52e66b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,5 +3,5 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("new", "routes/new.ts"), - route("docs/:id", "routes/docs.$id.tsx"), + route(":id", "routes/doc.$id.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/docs.$id.tsx b/app/routes/doc.$id.tsx similarity index 99% rename from app/routes/docs.$id.tsx rename to app/routes/doc.$id.tsx index 750375ac..1637fadf 100644 --- a/app/routes/docs.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -1,5 +1,5 @@ import { data, Link } from "react-router"; -import type { Route } from "./+types/docs.$id"; +import type { Route } from "./+types/doc.$id"; import { getAgentByName } from "agents"; import { isValidDocumentId, DOCUMENT_TTL_MS } from "~/shared/constants"; import { getCloudflare } from "~/lib/cloudflare.server"; diff --git a/app/routes/home.tsx b/app/routes/home.tsx index b337ba71..f283a865 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -40,7 +40,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: body, threads, onboarding }), }); - navigate(`/docs/${id}`); + navigate(`/${id}`); } const handleUpload = useCallback( @@ -56,7 +56,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { body: JSON.stringify({ content: body, threads }), }); - navigate(`/docs/${id}`); + navigate(`/${id}`); }, [navigate], ); diff --git a/app/routes/new.ts b/app/routes/new.ts index c6669f49..61a053d0 100644 --- a/app/routes/new.ts +++ b/app/routes/new.ts @@ -59,7 +59,7 @@ export async function action({ request, context }: Route.ActionArgs) { } const url = new URL(request.url); - return new Response(`${url.origin}/docs/${id}\n`, { + return new Response(`${url.origin}/${id}\n`, { status: 201, headers: { "Content-Type": "text/plain" }, }); diff --git a/tests/unit/routes/new.test.ts b/tests/unit/routes/new.test.ts index cf160353..fec0d98b 100644 --- a/tests/unit/routes/new.test.ts +++ b/tests/unit/routes/new.test.ts @@ -82,7 +82,7 @@ describe("POST /new (action)", () => { expect(response.headers.get("Content-Type")).toBe("text/plain"); const text = await response.text(); - expect(text).toBe("https://mist.example.com/docs/abcd1234\n"); + expect(text).toBe("https://mist.example.com/abcd1234\n"); }); it("returns 201 for empty body (blank document)", async () => { @@ -91,7 +91,7 @@ describe("POST /new (action)", () => { expect(response.status).toBe(201); const text = await response.text(); - expect(text).toContain("/docs/abcd1234"); + expect(text).toContain("/abcd1234"); }); it("creates document via agent with content", async () => { @@ -113,7 +113,7 @@ describe("POST /new (action)", () => { expect(response.status).toBe(201); const text = await response.text(); - expect(text).toContain("/docs/abcd1234"); + expect(text).toContain("/abcd1234"); const agentRequest = mockAgentFetch.mock.calls[0][0] as Request; const body = await agentRequest.json(); diff --git a/tests/unit/routes/root-path.test.ts b/tests/unit/routes/root-path.test.ts new file mode 100644 index 00000000..be44c9f3 --- /dev/null +++ b/tests/unit/routes/root-path.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import routes from "~/routes"; + +describe("route table", () => { + it("serves documents at /:id, not /docs/:id", () => { + const flat = JSON.stringify(routes); + expect(flat).toContain('":id"'); + expect(flat).not.toContain("docs/:id"); + }); +}); From 34f2e012c265925be5642c29085644af55372548 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:25:09 -0700 Subject: [PATCH 10/83] Add per-document agent token roster Adds mint/verify/revoke/list for per-document agent tokens: app/lib/agent-tokens.ts generates and hashes tokens (SHA-256), and DocumentAgent gains an agent_tokens SQLite table plus mintAgentToken/getAgentRoster/revokeAgentToken/verifyAgentToken RPC methods. Extends the integration test's sql mock with a generic in-memory table store for tables beyond doc_state, reusable by later agent tasks. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 161 +++++++++++++++++- app/lib/agent-tokens.ts | 26 +++ .../integration/agents/document-agent.test.ts | 161 +++++++++++++++++- tests/unit/lib/agent-tokens.test.ts | 14 ++ 4 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 app/lib/agent-tokens.ts create mode 100644 tests/unit/lib/agent-tokens.test.ts diff --git a/agents/document.ts b/agents/document.ts index 80cc9538..3b33da76 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -5,7 +5,10 @@ import * as syncProtocol from "y-protocols/sync"; import * as awarenessProtocol from "y-protocols/awareness"; import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; -import { MSG_SYNC, MSG_AWARENESS, DOCUMENT_TTL_MS, DOC_FORMAT_VERSION } from "../app/shared/constants"; +import { MSG_SYNC, MSG_AWARENESS, DOCUMENT_TTL_MS, DOC_FORMAT_VERSION, USER_COLOURS } from "../app/shared/constants"; +import type { AgentCapability, AgentRosterEntry, AgentError } from "../app/shared/agent-protocol"; +import { AGENT_NAME_RE, DEFAULT_CAPABILITIES } from "../app/shared/agent-protocol"; +import { generateAgentToken, hashToken } from "../app/lib/agent-tokens"; /** * Durable Objects SQLite accepts Uint8Array for BLOB columns via the @@ -16,6 +19,27 @@ function sqlBlob(data: Uint8Array): string { return data as unknown as string; } +interface AgentTokenRow { + token_hash: string; + name: string; + color: string; + owner: string | null; + capabilities: string; + created_at: number; + last_seen_at: number | null; +} + +function rowToRosterEntry(row: AgentTokenRow): AgentRosterEntry { + return { + name: row.name, + color: row.color, + owner: row.owner, + capabilities: JSON.parse(row.capabilities) as AgentCapability[], + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + }; +} + class DocumentAgent extends Agent { private doc: Y.Doc | null = null; private awareness: awarenessProtocol.Awareness | null = null; @@ -28,13 +52,24 @@ class DocumentAgent extends Agent { this.doc = new Y.Doc(); this.awareness = new awarenessProtocol.Awareness(this.doc); - // Create table if needed + // Create tables if needed this.sql` CREATE TABLE IF NOT EXISTS doc_state ( key TEXT PRIMARY KEY, value BLOB ) `; + this.sql` + CREATE TABLE IF NOT EXISTS agent_tokens ( + token_hash TEXT PRIMARY KEY, + name TEXT UNIQUE, + color TEXT, + owner TEXT, + capabilities TEXT, + created_at INTEGER, + last_seen_at INTEGER + ) + `; // Load persisted state const rows = this.sql<{ value: ArrayBuffer }>` @@ -243,10 +278,7 @@ class DocumentAgent extends Agent { if (request.method === "GET") { // Check whether this document exists this.ensureInitialised(); - const rows = this.sql<{ value: ArrayBuffer }>` - SELECT value FROM doc_state WHERE key = 'exists' - `; - const exists = rows.length > 0; + const exists = this.docExists(); const createdAtRows = this.sql<{ value: ArrayBuffer }>` SELECT value FROM doc_state WHERE key = 'createdAt' @@ -264,6 +296,123 @@ class DocumentAgent extends Agent { return new Response("Not found", { status: 404 }); } + /** Whether this document has been created (POSTed to) yet. */ + private docExists(): boolean { + const rows = this.sql<{ value: ArrayBuffer }>` + SELECT value FROM doc_state WHERE key = 'exists' + `; + return rows.length > 0; + } + + /** + * Mints a new agent token for this document, assigning it a slug name, + * a roster color (round-robin over USER_COLOURS), and a set of + * capabilities. Only the SHA-256 hash of the token is stored. + */ + async mintAgentToken(opts: { + name: string; + owner?: string; + capabilities?: AgentCapability[]; + }): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }> { + this.ensureInitialised(); + + if (!this.docExists()) { + return { error: { code: "doc_not_found", message: "Document does not exist" } }; + } + + if (!AGENT_NAME_RE.test(opts.name)) { + return { + error: { code: "invalid_name", message: `Invalid agent name: ${opts.name}` }, + }; + } + + const existing = this.sql<{ name: string }>` + SELECT name FROM agent_tokens WHERE name = ${opts.name} + `; + if (existing.length > 0) { + return { + error: { code: "invalid_name", message: `Agent name already taken: ${opts.name}` }, + }; + } + + const roster = this.sql<{ name: string }>`SELECT name FROM agent_tokens`; + const color = USER_COLOURS[roster.length % USER_COLOURS.length].color; + + const token = generateAgentToken(); + const tokenHash = await hashToken(token); + const capabilities = opts.capabilities ?? DEFAULT_CAPABILITIES; + const owner = opts.owner ?? null; + const createdAt = Date.now(); + + this.sql` + INSERT INTO agent_tokens (token_hash, name, color, owner, capabilities, created_at, last_seen_at) + VALUES (${tokenHash}, ${opts.name}, ${color}, ${owner}, ${JSON.stringify(capabilities)}, ${createdAt}, ${null}) + `; + + return { + token, + entry: { + name: opts.name, + color, + owner, + capabilities, + createdAt, + lastSeenAt: null, + }, + }; + } + + /** Lists all agents minted for this document, oldest first. */ + async getAgentRoster(): Promise { + this.ensureInitialised(); + const rows = this.sql` + SELECT * FROM agent_tokens ORDER BY created_at ASC + `; + return rows.map(rowToRosterEntry); + } + + /** Revokes an agent's token by name. Idempotent. */ + async revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }> { + this.ensureInitialised(); + this.sql`DELETE FROM agent_tokens WHERE name = ${name}`; + return { ok: true }; + } + + /** + * Verifies a presented agent token, optionally checking it carries a + * needed capability. `read` is implied by any valid token and is never + * stored in `capabilities`, so omit `needs` to check validity only. + * Updates `last_seen_at` on success. Used internally by every + * agent-facing RPC method. + */ + private async verifyAgentToken( + token: string, + needs?: AgentCapability, + ): Promise<{ entry: AgentRosterEntry } | { error: AgentError }> { + this.ensureInitialised(); + + const tokenHash = await hashToken(token); + const rows = this.sql` + SELECT * FROM agent_tokens WHERE token_hash = ${tokenHash} + `; + if (rows.length === 0) { + return { error: { code: "invalid_token", message: "Invalid or unknown agent token" } }; + } + + const row = rows[0]; + const capabilities = JSON.parse(row.capabilities) as AgentCapability[]; + if (needs && !capabilities.includes(needs)) { + return { + error: { code: "capability_denied", message: `Agent lacks capability: ${needs}` }, + }; + } + + const now = Date.now(); + this.sql`UPDATE agent_tokens SET last_seen_at = ${now} WHERE token_hash = ${tokenHash}`; + + return { entry: rowToRosterEntry({ ...row, last_seen_at: now }) }; + } + private broadcastBinary(message: WSMessage, excludeId: string) { // Make a clean copy to avoid ArrayBufferView offset issues const bytes = diff --git a/app/lib/agent-tokens.ts b/app/lib/agent-tokens.ts new file mode 100644 index 00000000..da82160e --- /dev/null +++ b/app/lib/agent-tokens.ts @@ -0,0 +1,26 @@ +const TOKEN_PREFIX = "vpr_"; +const TOKEN_RANDOM_BYTES = 32; + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Generates a new agent token: "vpr_" + 43 base64url chars (32 random bytes). */ +export function generateAgentToken(): string { + const bytes = new Uint8Array(TOKEN_RANDOM_BYTES); + crypto.getRandomValues(bytes); + return TOKEN_PREFIX + toBase64Url(bytes); +} + +/** Hashes a token to a stable 64-char hex SHA-256 digest for storage/lookup. */ +export async function hashToken(token: string): Promise { + const data = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 6b09b426..7a97687d 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -23,6 +23,14 @@ import { YjsProvider } from "~/lib/yjs-provider"; let mockSqlStore: Map; let mockConnectionMap: Map; let mockSetAlarm: ReturnType; +/** + * Generic in-memory table store for tables other than `doc_state` + * (currently `agent_tokens`; later tasks add `performances`/`events`). + * Keyed by table name -> array of row objects. Query-shaped, not a real + * SQL engine: it pattern-matches the exact INSERT/SELECT/UPDATE/DELETE + * forms the DO code uses, mirroring the `doc_state` fake above. + */ +let mockTables: Map>>; vi.mock("agents", () => ({ Agent: class MockAgent { @@ -37,7 +45,8 @@ vi.mock("agents", () => ({ }; sql(strings: TemplateStringsArray, ...values: unknown[]) { - const query = strings.join("$").toLowerCase().trim(); + const raw = strings.join("$"); + const query = raw.toLowerCase().trim(); if (query.includes("create table")) return []; @@ -69,6 +78,72 @@ vi.mock("agents", () => ({ return []; } + // Generic table store, matched by table name in the query. + const tableMatch = /(?:from|into|update)\s+(\w+)/.exec(query); + if (tableMatch) { + const table = tableMatch[1]; + if (!mockTables.has(table)) mockTables.set(table, []); + const rows = mockTables.get(table)!; + + if (query.startsWith("insert into")) { + const colsMatch = /\(([^)]+)\)\s*values/i.exec(raw); + if (colsMatch) { + const cols = colsMatch[1].split(",").map((c) => c.trim()); + const row: Record = {}; + cols.forEach((col, i) => { + row[col] = values[i]; + }); + rows.push(row); + } + return []; + } + + if (query.startsWith("update")) { + const setMatch = /set\s+(\w+)\s*=/i.exec(raw); + const whereMatch = /where\s+(\w+)\s*=/i.exec(raw); + if (setMatch && whereMatch) { + const [setVal, whereVal] = values; + for (const row of rows) { + if (row[whereMatch[1]] === whereVal) row[setMatch[1]] = setVal; + } + } + return []; + } + + if (query.startsWith("delete from")) { + const whereMatch = /where\s+(\w+)\s*=/i.exec(raw); + if (whereMatch) { + const whereVal = values[0]; + mockTables.set( + table, + rows.filter((row) => row[whereMatch[1]] !== whereVal), + ); + } else { + mockTables.set(table, []); + } + return []; + } + + if (query.startsWith("select")) { + const whereMatch = /where\s+(\w+)\s*=/i.exec(raw); + let result = whereMatch + ? rows.filter((row) => row[whereMatch[1]] === values[0]) + : rows; + + const orderMatch = /order by\s+(\w+)/i.exec(raw); + if (orderMatch) { + const col = orderMatch[1]; + result = [...result].sort((a, b) => { + const av = a[col] as number; + const bv = b[col] as number; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + } + + return result.map((row) => ({ ...row })); + } + } + return []; } @@ -154,6 +229,7 @@ describe("DocumentAgent", () => { vi.stubGlobal("WebSocket", MockSocket); mockSqlStore = new Map(); mockConnectionMap = new Map(); + mockTables = new Map(); mockSetAlarm = vi.fn(); nextConnId = 1; @@ -559,4 +635,87 @@ describe("DocumentAgent", () => { await agent.onClose(conn as never, 1000, "normal", true); }); }); + + /* ================================================================ */ + /* Agent token roster */ + /* ================================================================ */ + + describe("agent roster", () => { + /** verifyAgentToken is private on DocumentAgent; cast to call it from tests. */ + function asVerifier(a: InstanceType) { + return a as unknown as { + verifyAgentToken( + token: string, + needs?: string, + ): Promise<{ entry: unknown } | { error: { code: string } }>; + }; + } + + it("mints, lists, verifies capability, revokes", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + + const minted = await agent.mintAgentToken({ name: "scribe" }); + expect("token" in minted && minted.token).toMatch(/^vpr_/); + expect((await agent.getAgentRoster())[0]).toMatchObject({ + name: "scribe", + capabilities: ["suggest", "comment"], + }); + + // Default grant lacks write. + const v = await asVerifier(agent).verifyAgentToken( + (minted as { token: string }).token, + "write", + ); + expect(v).toMatchObject({ error: { code: "capability_denied" } }); + + await agent.revokeAgentToken("scribe"); + expect(await agent.getAgentRoster()).toHaveLength(0); + }); + + it("rejects bad names and duplicates", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + + expect(await agent.mintAgentToken({ name: "Bad Name" })).toMatchObject({ + error: { code: "invalid_name" }, + }); + + await agent.mintAgentToken({ name: "scribe" }); + expect(await agent.mintAgentToken({ name: "scribe" })).toMatchObject({ + error: { code: "invalid_name" }, + }); + }); + + it("returns doc_not_found when minting before the doc exists", async () => { + expect(await agent.mintAgentToken({ name: "scribe" })).toMatchObject({ + error: { code: "doc_not_found" }, + }); + }); + + it("returns invalid_token for an unknown token", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + const v = await asVerifier(agent).verifyAgentToken("vpr_nonexistent"); + expect(v).toMatchObject({ error: { code: "invalid_token" } }); + }); + + it("verifies a granted capability and updates lastSeenAt", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + const minted = await agent.mintAgentToken({ name: "scribe" }); + const token = (minted as { token: string }).token; + + const v = await asVerifier(agent).verifyAgentToken(token, "suggest"); + expect(v).toMatchObject({ entry: { name: "scribe" } }); + + const [entry] = await agent.getAgentRoster(); + expect(entry.lastSeenAt).not.toBeNull(); + }); + + it("assigns roster colors round-robin by roster size", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + await agent.mintAgentToken({ name: "first" }); + await agent.mintAgentToken({ name: "second" }); + + const roster = await agent.getAgentRoster(); + expect(roster[0].color).not.toBe(roster[1].color); + }); + }); }); diff --git a/tests/unit/lib/agent-tokens.test.ts b/tests/unit/lib/agent-tokens.test.ts new file mode 100644 index 00000000..4a5feabf --- /dev/null +++ b/tests/unit/lib/agent-tokens.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { generateAgentToken, hashToken } from "~/lib/agent-tokens"; + +describe("agent tokens", () => { + it("generates prefixed unique tokens", () => { + const t = generateAgentToken(); + expect(t).toMatch(/^vpr_[A-Za-z0-9_-]{43}$/); + expect(generateAgentToken()).not.toBe(t); + }); + it("hashes stably to 64 hex chars", async () => { + expect(await hashToken("vpr_x")).toBe(await hashToken("vpr_x")); + expect(await hashToken("vpr_x")).toMatch(/^[0-9a-f]{64}$/); + }); +}); From 2773142de1cb60b4f26ca040c0ad2e01a3f2efcf Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:30:58 -0700 Subject: [PATCH 11/83] Clear agent_tokens on document expiry alarm() only deleted doc_state, so tokens minted before a document expired stayed valid against whatever content later landed at the same doc id. Clear agent_tokens alongside doc_state. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 4 ++++ tests/integration/agents/document-agent.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/agents/document.ts b/agents/document.ts index 3b33da76..9655bf2b 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -186,6 +186,10 @@ class DocumentAgent extends Agent { override readonly alarm = async (): Promise => { // Auto-delete: remove all document data this.sql`DELETE FROM doc_state`; + // Revoke every minted agent token along with the document — a token + // must not stay valid against whatever content lands at this doc id + // if it's recreated after expiry. + this.sql`DELETE FROM agent_tokens`; // Close all active WebSocket connections for (const conn of this.getConnections()) { conn.close(1000, "Document expired"); diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 7a97687d..8afc1d1f 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -717,5 +717,17 @@ describe("DocumentAgent", () => { const roster = await agent.getAgentRoster(); expect(roster[0].color).not.toBe(roster[1].color); }); + + it("clears the roster and invalidates tokens on doc expiry (alarm)", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + const minted = await agent.mintAgentToken({ name: "scribe" }); + const token = (minted as { token: string }).token; + + await agent.alarm(); + + expect(await agent.getAgentRoster()).toEqual([]); + const v = await asVerifier(agent).verifyAgentToken(token); + expect(v).toMatchObject({ error: { code: "invalid_token" } }); + }); }); }); From 73cf40c74857975ccb050fa66a3589e7201b2ba5 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:38:09 -0700 Subject: [PATCH 12/83] Add agent read and mutation RPCs with anchor checks Co-Authored-By: Claude Fable 5 --- agents/document.ts | 230 +++++++++++++++++- .../integration/agents/document-agent.test.ts | 96 +++++++- 2 files changed, 315 insertions(+), 11 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index 9655bf2b..ded7141d 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -6,9 +6,17 @@ import * as awarenessProtocol from "y-protocols/awareness"; import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import { MSG_SYNC, MSG_AWARENESS, DOCUMENT_TTL_MS, DOC_FORMAT_VERSION, USER_COLOURS } from "../app/shared/constants"; -import type { AgentCapability, AgentRosterEntry, AgentError } from "../app/shared/agent-protocol"; -import { AGENT_NAME_RE, DEFAULT_CAPABILITIES } from "../app/shared/agent-protocol"; +import type { AgentCapability, AgentRosterEntry, AgentError, Pace } from "../app/shared/agent-protocol"; +import { + AGENT_NAME_RE, + DEFAULT_CAPABILITIES, + formatAnchor, + RATE_LIMIT_MUTATIONS_PER_MIN, + RATE_LIMIT_CHARS_PER_HOUR, +} from "../app/shared/agent-protocol"; import { generateAgentToken, hashToken } from "../app/lib/agent-tokens"; +import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteBlocks } from "../app/lib/y-markdown"; +import type { ThreadData } from "../app/shared/types"; /** * Durable Objects SQLite accepts Uint8Array for BLOB columns via the @@ -27,6 +35,14 @@ interface AgentTokenRow { capabilities: string; created_at: number; last_seen_at: number | null; + /** JSON array of { at: epoch-ms, chars: number }, pruned to the last hour. */ + recent_mutations?: string | null; +} + +/** One recorded mutation, used for rate-limiting agent writes. */ +interface MutationLogEntry { + at: number; + chars: number; } function rowToRosterEntry(row: AgentTokenRow): AgentRosterEntry { @@ -67,7 +83,8 @@ class DocumentAgent extends Agent { owner TEXT, capabilities TEXT, created_at INTEGER, - last_seen_at INTEGER + last_seen_at INTEGER, + recent_mutations TEXT ) `; @@ -417,6 +434,213 @@ class DocumentAgent extends Agent { return { entry: rowToRosterEntry({ ...row, last_seen_at: now }) }; } + /** + * Checks and records rate-limit usage for a token ahead of a mutation of + * `chars` characters. Denies with `rate_limited` when the token has made + * more than `RATE_LIMIT_MUTATIONS_PER_MIN` mutations in the last 60s, or + * written more than `RATE_LIMIT_CHARS_PER_HOUR` characters in the last + * hour. On success, records this attempt. The log is pruned to the last + * hour on every check regardless of outcome. + */ + private async checkRateLimit(token: string, chars: number): Promise<{ error: AgentError } | null> { + const tokenHash = await hashToken(token); + const rows = this.sql<{ recent_mutations: string | null }>` + SELECT recent_mutations FROM agent_tokens WHERE token_hash = ${tokenHash} + `; + + const now = Date.now(); + const hourAgo = now - 60 * 60 * 1000; + const minuteAgo = now - 60 * 1000; + + const raw = rows[0]?.recent_mutations; + const log = (raw ? JSON.parse(raw) : []) as MutationLogEntry[]; + const pruned = log.filter((e) => e.at > hourAgo); + + const recentCount = pruned.filter((e) => e.at > minuteAgo).length; + const totalChars = pruned.reduce((sum, e) => sum + e.chars, 0); + + if (recentCount >= RATE_LIMIT_MUTATIONS_PER_MIN || totalChars + chars > RATE_LIMIT_CHARS_PER_HOUR) { + this.sql`UPDATE agent_tokens SET recent_mutations = ${JSON.stringify(pruned)} WHERE token_hash = ${tokenHash}`; + return { error: { code: "rate_limited", message: "Agent mutation rate limit exceeded" } }; + } + + pruned.push({ at: now, chars }); + this.sql`UPDATE agent_tokens SET recent_mutations = ${JSON.stringify(pruned)} WHERE token_hash = ${tokenHash}`; + return null; + } + + /** + * Returns the document's full markdown, per-block anchors, current + * presence (humans from awareness, agents from the roster), and comment + * threads. Any valid token can read; no capability is required. + */ + async agentRead(token: string): Promise< + | { + markdown: string; + blocks: { anchor: string; text: string }[]; + presence: { name: string; isAgent: boolean }[]; + threads: ThreadData[]; + } + | { error: AgentError } + > { + const verified = await this.verifyAgentToken(token); + if ("error" in verified) return verified; + + const { doc, awareness } = this.ensureInitialised(); + + const markdown = yDocToMarkdown(doc); + const blocks = getBlocks(doc).map((b) => ({ anchor: formatAnchor(b), text: b.text })); + + const presence: { name: string; isAgent: boolean }[] = []; + for (const state of awareness.getStates().values()) { + const user = (state as { user?: { name?: string } }).user; + if (user?.name) presence.push({ name: user.name, isAgent: false }); + } + + const now = Date.now(); + const roster = await this.getAgentRoster(); + for (const entry of roster) { + if (entry.lastSeenAt != null && now - entry.lastSeenAt < 5 * 60 * 1000) { + presence.push({ name: entry.name, isAgent: true }); + } + } + + const threadsMap = doc.getMap("threads"); + const threads: ThreadData[] = []; + threadsMap.forEach((value) => { + threads.push(JSON.parse(value) as ThreadData); + }); + + return { markdown, blocks, presence, threads }; + } + + /** + * Inserts markdown as new blocks. `where: "append"` needs no anchor; + * otherwise the anchor is resolved and the blocks are inserted directly + * before or after it. Requires `write`. + */ + async agentInsert( + token: string, + args: { anchor?: string; where: "before" | "after" | "append"; markdown: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token, "write"); + if ("error" in verified) return verified; + + const rateLimited = await this.checkRateLimit(token, args.markdown.length); + if (rateLimited) return rateLimited; + + const { doc } = this.ensureInitialised(); + + let index: number; + if (args.where === "append") { + index = getBlocks(doc).length; + } else { + if (!args.anchor) { + return { + error: { code: "stale_anchor", message: `An anchor is required for where: "${args.where}"` }, + }; + } + const resolved = resolveAnchor(doc, args.anchor); + if ("error" in resolved) { + return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + } + index = args.where === "before" ? resolved.index : resolved.index + 1; + } + + doc.transact(() => { + insertMarkdownBlocks(doc, index, args.markdown); + }); + + return { ok: true }; + } + + /** + * Replaces the block range [from, to] (anchors, `to` defaults to `from`) + * with new markdown, in one transaction. Requires `write`. + */ + async agentReplace( + token: string, + args: { from: string; to?: string; markdown: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token, "write"); + if ("error" in verified) return verified; + + const rateLimited = await this.checkRateLimit(token, args.markdown.length); + if (rateLimited) return rateLimited; + + const { doc } = this.ensureInitialised(); + + const fromResolved = resolveAnchor(doc, args.from); + if ("error" in fromResolved) { + return { error: { code: fromResolved.error, message: "Anchor not found", snippet: fromResolved.snippet } }; + } + const toResolved = resolveAnchor(doc, args.to ?? args.from); + if ("error" in toResolved) { + return { error: { code: toResolved.error, message: "Anchor not found", snippet: toResolved.snippet } }; + } + + const fromIndex = fromResolved.index; + const toIndex = toResolved.index; + + doc.transact(() => { + deleteBlocks(doc, fromIndex, toIndex); + insertMarkdownBlocks(doc, fromIndex, args.markdown); + }); + + return { ok: true }; + } + + /** + * Suggests a replacement inside a block: marks `find` as a critic + * deletion and inserts `replacement` as a critic addition, mirroring the + * marks TipTap's suggest-mode plugin applies for human edits (no + * author-metadata attrs — see app/lib/suggest-mode.ts). Requires + * `suggest`. + */ + async agentSuggest( + token: string, + args: { anchor: string; find: string; replacement: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token, "suggest"); + if ("error" in verified) return verified; + + const rateLimited = await this.checkRateLimit(token, args.find.length + args.replacement.length); + if (rateLimited) return rateLimited; + + const { doc } = this.ensureInitialised(); + + const resolved = resolveAnchor(doc, args.anchor); + if ("error" in resolved) { + return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + } + + const frag = doc.getXmlFragment("default"); + const el = frag.get(resolved.index); + const ytext = el instanceof Y.XmlElement + ? el.toArray().find((child): child is Y.XmlText => child instanceof Y.XmlText) + : undefined; + + const block = getBlocks(doc)[resolved.index]; + if (!ytext) { + return { error: { code: "find_not_matched", message: "Block has no text", snippet: block?.text ?? "" } }; + } + + const cleanText = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); + const pos = cleanText.indexOf(args.find); + if (pos === -1) { + return { + error: { code: "find_not_matched", message: "Could not find text to suggest a change on", snippet: block.text }, + }; + } + + doc.transact(() => { + ytext.format(pos, args.find.length, { criticDeletion: {} }); + ytext.insert(pos + args.find.length, args.replacement, { criticAddition: {} }); + }); + + return { ok: true }; + } + private broadcastBinary(message: WSMessage, excludeId: string) { // Make a clean copy to avoid ArrayBufferView offset issues const bytes = diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 8afc1d1f..86bff21c 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -15,6 +15,7 @@ import * as Y from "yjs"; import * as awarenessProtocol from "y-protocols/awareness"; import { DOCUMENT_TTL_MS, DOC_FORMAT_VERSION } from "~/shared/constants"; import { YjsProvider } from "~/lib/yjs-provider"; +import type { AgentCapability } from "~/shared/agent-protocol"; /* ------------------------------------------------------------------ */ /* Mock Agent base class */ @@ -44,6 +45,21 @@ vi.mock("agents", () => ({ }, }; + // Captured by reference at construction time (not a live binding to the + // outer `let`), so each `new MockAgent()` gets whatever store the + // module-level variables currently point to. The default `beforeEach` + // creates one agent per test, so this is transparent there. Tests that + // need several independent documents in a single test (agent-mutation + // tests) reassign the module-level maps to fresh ones immediately + // before constructing each additional agent, so its captured + // references never alias an earlier agent's store. Conversely, the + // "restore from persisted state" test constructs a second agent + // *without* reassigning the maps in between, so it deliberately + // shares the first agent's store (simulating a DO reload). + private _sqlStore = mockSqlStore; + private _tables = mockTables; + private _connections = mockConnectionMap; + sql(strings: TemplateStringsArray, ...values: unknown[]) { const raw = strings.join("$"); const query = raw.toLowerCase().trim(); @@ -51,14 +67,14 @@ vi.mock("agents", () => ({ if (query.includes("create table")) return []; if (query.includes("delete from doc_state")) { - mockSqlStore.clear(); + this._sqlStore.clear(); return []; } if (query.includes("select") && query.includes("from doc_state")) { const match = query.match(/key\s*=\s*'(\w+)'/); if (match) { - const buf = mockSqlStore.get(match[1]); + const buf = this._sqlStore.get(match[1]); if (buf) return [{ value: buf }]; } return []; @@ -69,7 +85,7 @@ vi.mock("agents", () => ({ if (match) { const val = values[0]; if (val instanceof Uint8Array) { - mockSqlStore.set( + this._sqlStore.set( match[1], val.buffer.slice(val.byteOffset, val.byteOffset + val.byteLength), ); @@ -82,8 +98,8 @@ vi.mock("agents", () => ({ const tableMatch = /(?:from|into|update)\s+(\w+)/.exec(query); if (tableMatch) { const table = tableMatch[1]; - if (!mockTables.has(table)) mockTables.set(table, []); - const rows = mockTables.get(table)!; + if (!this._tables.has(table)) this._tables.set(table, []); + const rows = this._tables.get(table)!; if (query.startsWith("insert into")) { const colsMatch = /\(([^)]+)\)\s*values/i.exec(raw); @@ -114,12 +130,12 @@ vi.mock("agents", () => ({ const whereMatch = /where\s+(\w+)\s*=/i.exec(raw); if (whereMatch) { const whereVal = values[0]; - mockTables.set( + this._tables.set( table, rows.filter((row) => row[whereMatch[1]] !== whereVal), ); } else { - mockTables.set(table, []); + this._tables.set(table, []); } return []; } @@ -148,7 +164,7 @@ vi.mock("agents", () => ({ } getConnections() { - return mockConnectionMap.values(); + return this._connections.values(); } }, })); @@ -251,6 +267,19 @@ describe("DocumentAgent", () => { return conn; } + /** + * Create a new DocumentAgent backed by its own fresh, isolated SQL store + * — simulating a distinct document (distinct Durable Object instance) + * rather than the single `agent` from `beforeEach`. See the MockAgent + * comment above for how isolation is achieved. + */ + function makeAgent(): InstanceType { + mockSqlStore = new Map(); + mockTables = new Map(); + mockConnectionMap = new Map(); + return new DocumentAgent({} as never, {} as never); + } + /** * Connect a full Yjs client through the agent. * @@ -730,4 +759,55 @@ describe("DocumentAgent", () => { expect(v).toMatchObject({ error: { code: "invalid_token" } }); }); }); + + /* ================================================================ */ + /* Agent read + instant mutations */ + /* ================================================================ */ + + describe("agent mutations", () => { + async function setup(caps?: AgentCapability[]) { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# Title\n\nBody." }), + })); + const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); + return { agent, token: (m as { token: string }).token }; + } + + it("reads markdown with anchors", async () => { + const { agent, token } = await setup(); + const r = await agent.agentRead(token); + expect("markdown" in r && r.markdown).toBe("# Title\n\nBody."); + expect("blocks" in r && r.blocks[0].anchor).toMatch(/^b0-[0-9a-f]{8}$/); + }); + + it("denies write without capability, allows with it", async () => { + const { agent, token } = await setup(); // default: no write + const denied = await agent.agentInsert(token, { where: "append", markdown: "More." }); + expect(denied).toMatchObject({ error: { code: "capability_denied" } }); + const { agent: a2, token: t2 } = await setup(["write"]); + await a2.agentInsert(t2, { where: "append", markdown: "More." }); + const r = await a2.agentRead(t2); + expect("markdown" in r && r.markdown).toContain("More."); + }); + + it("suggest lays critic marks", async () => { + const { agent, token } = await setup(); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[2].anchor; // "Body." + await agent.agentSuggest(token, { anchor, find: "Body.", replacement: "Better body." }); + const after = await agent.agentRead(token); + expect("markdown" in after && after.markdown).toContain("{--Body.--}{++Better body.++}"); + }); + + it("stale anchor errors after concurrent edit", async () => { + const { agent, token } = await setup(["write"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + await agent.agentReplace(token, { from: anchor, markdown: "# New title" }); + const stale = await agent.agentReplace(token, { from: anchor, markdown: "# Again" }); + expect(stale).toMatchObject({ error: { code: "stale_anchor" } }); + }); + }); }); From 6cc81d2fa993f721832e7da9836953fe050dcd38 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:45:03 -0700 Subject: [PATCH 13/83] Fix agentReplace silently no-opping delete on inverted to --- agents/document.ts | 14 ++++++++++ .../integration/agents/document-agent.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/agents/document.ts b/agents/document.ts index ded7141d..16d805ed 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -582,6 +582,20 @@ class DocumentAgent extends Agent { const fromIndex = fromResolved.index; const toIndex = toResolved.index; + if (toIndex < fromIndex) { + const snippet = getBlocks(doc) + .slice(0, 6) + .map((b) => `[b${b.index} ${b.hash}] ${b.text.slice(0, 60)}`) + .join("\n"); + return { + error: { + code: "stale_anchor", + message: `Anchor range resolved out of order: "to" (block ${toIndex}) is before "from" (block ${fromIndex}). Re-read the document and retry with fresh anchors.`, + snippet, + }, + }; + } + doc.transact(() => { deleteBlocks(doc, fromIndex, toIndex); insertMarkdownBlocks(doc, fromIndex, args.markdown); diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 86bff21c..3b721ba4 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -809,5 +809,32 @@ describe("DocumentAgent", () => { const stale = await agent.agentReplace(token, { from: anchor, markdown: "# Again" }); expect(stale).toMatchObject({ error: { code: "stale_anchor" } }); }); + + it("rejects an inverted range when to resolves before from", async () => { + const { agent, token } = await setup(["write"]); + // Append two blocks with identical text ("Same") so they share a + // content hash. resolveAnchor's nearest-index heuristic then lets us + // pick out either occurrence by fabricating an anchor whose *stated* + // index is far from one occurrence and close to the other. + await agent.agentInsert(token, { where: "append", markdown: "Same\nOther\nSame\nEnd" }); + + const before = await agent.agentRead(token); + const beforeMarkdown = "markdown" in before ? before.markdown : ""; + const blocks = "blocks" in before ? before.blocks : []; + const sameBlocks = blocks.filter((b) => b.text === "Same"); + expect(sameBlocks).toHaveLength(2); // real indices 3 and 5 + + const hash = sameBlocks[0].anchor.split("-")[1]; + // "from" resolves to the later occurrence (nearest to stated index 100). + const fromAnchor = `b100-${hash}`; + // "to" resolves to the earlier occurrence (nearest to stated index 0). + const toAnchor = `b0-${hash}`; + + const result = await agent.agentReplace(token, { from: fromAnchor, to: toAnchor, markdown: "Nope" }); + expect(result).toMatchObject({ error: { code: "stale_anchor" } }); + + const after = await agent.agentRead(token); + expect("markdown" in after && after.markdown).toBe(beforeMarkdown); + }); }); }); From 2bbc906cdd4f2b4b9f191e59b1ad7650bccfde11 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 09:59:10 -0700 Subject: [PATCH 14/83] Add performance engine for paced agent edits DocumentAgent's mutation RPCs (agentInsert/agentReplace/agentSuggest) now share a private applyMutation() for direct Yjs application. A pace of natural/fast with at least one connected human enqueues the mutation into a `performances` table instead of applying it instantly; a setTimeout-chain runner types insert/suggest text out via app/lib/performance-chunks.ts chunks so connected clients see it appear incrementally, while replace still applies atomically once dequeued. Anchors are re-resolved at dequeue time (not enqueue time) so a mutation gone stale while queued is simply dropped. Leftover queue rows are applied instantly on the next ensureInitialised() (eviction recovery), and the alarm handler now also purges the performances table. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 463 +++++++++++++++--- app/lib/performance-chunks.ts | 64 +++ .../integration/agents/document-agent.test.ts | 121 +++++ tests/unit/lib/performance-chunks.test.ts | 49 ++ 4 files changed, 627 insertions(+), 70 deletions(-) create mode 100644 app/lib/performance-chunks.ts create mode 100644 tests/unit/lib/performance-chunks.test.ts diff --git a/agents/document.ts b/agents/document.ts index 16d805ed..ccbb68a9 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -16,6 +16,8 @@ import { } from "../app/shared/agent-protocol"; import { generateAgentToken, hashToken } from "../app/lib/agent-tokens"; import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteBlocks } from "../app/lib/y-markdown"; +import { parseCriticMarkupToContent } from "../app/lib/critic-parser"; +import { chunkTyping } from "../app/lib/performance-chunks"; import type { ThreadData } from "../app/shared/types"; /** @@ -27,6 +29,37 @@ function sqlBlob(data: Uint8Array): string { return data as unknown as string; } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** The Yjs-application-only part of a mutation, shared by all three RPCs. */ +type MutationPayload = + | { kind: "insert"; anchor?: string; where: "before" | "after" | "append"; markdown: string } + | { kind: "replace"; from: string; to?: string; markdown: string } + | { kind: "suggest"; anchor: string; find: string; replacement: string }; + +/** + * A mutation either being applied instantly or sitting in the performance + * queue. `id`/`agentName`/`pace` are meaningless for the instant path (it + * never touches the `performances` table) — only the queue runner and + * eviction recovery care about them. + */ +interface PendingMutation { + id: number; + agentName: string; + pace: Pace; + mutation: MutationPayload; +} + +interface PerformanceRow { + id: number; + agent_name: string; + kind: string; + payload: string; + created_at: number; +} + interface AgentTokenRow { token_hash: string; name: string; @@ -60,6 +93,17 @@ class DocumentAgent extends Agent { private doc: Y.Doc | null = null; private awareness: awarenessProtocol.Awareness | null = null; + /** In-memory mirror of the `performances` table, drained by runPerformances(). */ + private performanceQueue: PendingMutation[] = []; + private isPerforming = false; + /** + * Assigns queue-row ids for this instance's lifetime. Reset to 1 on every + * fresh instantiation, which is safe because ensureInitialised() always + * drains (and deletes) any leftover `performances` rows before any new + * mutation can be enqueued. + */ + private nextPerformanceId = 1; + private ensureInitialised(): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { if (this.doc && this.awareness) { return { doc: this.doc, awareness: this.awareness }; @@ -87,6 +131,15 @@ class DocumentAgent extends Agent { recent_mutations TEXT ) `; + this.sql` + CREATE TABLE IF NOT EXISTS performances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_name TEXT, + kind TEXT, + payload TEXT, + created_at INTEGER + ) + `; // Load persisted state const rows = this.sql<{ value: ArrayBuffer }>` @@ -107,6 +160,19 @@ class DocumentAgent extends Agent { `; }); + // Eviction recovery: any row still in `performances` means the DO was + // evicted mid-performance (or between enqueue and its turn). There's no + // one left mid-typing to watch it happen, so apply each leftover + // mutation instantly, in the order it was queued, and drop the row. + const leftover = this.sql` + SELECT * FROM performances ORDER BY id ASC + `; + for (const row of leftover) { + const mutation = JSON.parse(row.payload) as MutationPayload; + this.applyMutation(mutation); + this.sql`DELETE FROM performances WHERE id = ${row.id}`; + } + return { doc: this.doc, awareness: this.awareness }; } @@ -207,6 +273,10 @@ class DocumentAgent extends Agent { // must not stay valid against whatever content lands at this doc id // if it's recreated after expiry. this.sql`DELETE FROM agent_tokens`; + // Any queued performances belong to a document that no longer exists. + this.sql`DELETE FROM performances`; + this.performanceQueue = []; + this.isPerforming = false; // Close all active WebSocket connections for (const conn of this.getConnections()) { conn.close(1000, "Document expired"); @@ -529,29 +599,18 @@ class DocumentAgent extends Agent { const rateLimited = await this.checkRateLimit(token, args.markdown.length); if (rateLimited) return rateLimited; - const { doc } = this.ensureInitialised(); - - let index: number; - if (args.where === "append") { - index = getBlocks(doc).length; - } else { - if (!args.anchor) { - return { - error: { code: "stale_anchor", message: `An anchor is required for where: "${args.where}"` }, - }; - } - const resolved = resolveAnchor(doc, args.anchor); - if ("error" in resolved) { - return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; - } - index = args.where === "before" ? resolved.index : resolved.index + 1; + if (args.where !== "append" && !args.anchor) { + return { + error: { code: "stale_anchor", message: `An anchor is required for where: "${args.where}"` }, + }; } - doc.transact(() => { - insertMarkdownBlocks(doc, index, args.markdown); + return this.dispatchMutation(verified.entry.name, args.pace, { + kind: "insert", + anchor: args.anchor, + where: args.where, + markdown: args.markdown, }); - - return { ok: true }; } /** @@ -568,40 +627,12 @@ class DocumentAgent extends Agent { const rateLimited = await this.checkRateLimit(token, args.markdown.length); if (rateLimited) return rateLimited; - const { doc } = this.ensureInitialised(); - - const fromResolved = resolveAnchor(doc, args.from); - if ("error" in fromResolved) { - return { error: { code: fromResolved.error, message: "Anchor not found", snippet: fromResolved.snippet } }; - } - const toResolved = resolveAnchor(doc, args.to ?? args.from); - if ("error" in toResolved) { - return { error: { code: toResolved.error, message: "Anchor not found", snippet: toResolved.snippet } }; - } - - const fromIndex = fromResolved.index; - const toIndex = toResolved.index; - - if (toIndex < fromIndex) { - const snippet = getBlocks(doc) - .slice(0, 6) - .map((b) => `[b${b.index} ${b.hash}] ${b.text.slice(0, 60)}`) - .join("\n"); - return { - error: { - code: "stale_anchor", - message: `Anchor range resolved out of order: "to" (block ${toIndex}) is before "from" (block ${fromIndex}). Re-read the document and retry with fresh anchors.`, - snippet, - }, - }; - } - - doc.transact(() => { - deleteBlocks(doc, fromIndex, toIndex); - insertMarkdownBlocks(doc, fromIndex, args.markdown); + return this.dispatchMutation(verified.entry.name, args.pace, { + kind: "replace", + from: args.from, + to: args.to, + markdown: args.markdown, }); - - return { ok: true }; } /** @@ -621,38 +652,330 @@ class DocumentAgent extends Agent { const rateLimited = await this.checkRateLimit(token, args.find.length + args.replacement.length); if (rateLimited) return rateLimited; + return this.dispatchMutation(verified.entry.name, args.pace, { + kind: "suggest", + anchor: args.anchor, + find: args.find, + replacement: args.replacement, + }); + } + + /** + * Decides whether a mutation is applied synchronously or handed to the + * performance queue. `pace: "instant"` (the default, for backward + * compatibility with callers that don't pass `pace` at all) or the + * absence of any connected human always applies immediately — there's no + * one to watch it type. Otherwise the mutation is persisted to + * `performances` and the queue runner picks it up. + */ + private dispatchMutation( + agentName: string, + pace: Pace | undefined, + mutation: MutationPayload, + ): { ok: true } | { error: AgentError } { + const effectivePace = pace ?? "instant"; + if (effectivePace !== "instant" && this.hasHumanConnections()) { + return this.enqueuePerformance(agentName, effectivePace, mutation); + } + return this.applyMutation(mutation); + } + + /** Whether any (human) WebSocket client is currently connected. */ + private hasHumanConnections(): boolean { + for (const _conn of this.getConnections()) { + return true; + } + return false; + } + + /** + * Persists a mutation to the `performances` table and appends it to the + * in-memory queue, kicking off the runner if it isn't already draining + * the queue. Anchors are stored verbatim (not resolved to indices) so + * they can be re-checked for staleness at dequeue time. + */ + private enqueuePerformance( + agentName: string, + pace: "natural" | "fast", + mutation: MutationPayload, + ): { ok: true } { + const id = this.nextPerformanceId++; + this.sql` + INSERT INTO performances (id, agent_name, kind, payload, created_at) + VALUES (${id}, ${agentName}, ${mutation.kind}, ${JSON.stringify(mutation)}, ${Date.now()}) + `; + this.performanceQueue.push({ id, agentName, pace, mutation }); + + if (!this.isPerforming) { + void this.runPerformances(); + } + + return { ok: true }; + } + + /** + * Drains the performance queue one mutation at a time, in FIFO order, + * removing each row from `performances` once it's fully applied (or + * dropped as stale). Runs for as long as the DO stays live; if it's + * evicted mid-queue, ensureInitialised()'s recovery step picks up + * whatever rows are left on the next wake-up. + */ + private async runPerformances(): Promise { + this.isPerforming = true; + while (this.performanceQueue.length > 0) { + const item = this.performanceQueue[0]; + await this.performQueuedMutation(item); + this.sql`DELETE FROM performances WHERE id = ${item.id}`; + this.performanceQueue.shift(); + } + this.isPerforming = false; + } + + /** + * Applies one queued mutation. `replace` has no meaningful "typing" + * animation (it's a delete-and-insert), so it applies atomically as soon + * as it's dequeued. `insert` and `suggest` type their new text out via + * chunkTyping ticks so connected humans see it appear incrementally. + */ + private async performQueuedMutation(item: PendingMutation): Promise { + const pace: "natural" | "fast" = item.pace === "fast" ? "fast" : "natural"; + + if (item.mutation.kind === "replace") { + this.applyMutation(item.mutation); + return; + } + if (item.mutation.kind === "insert") { + await this.performTypedInsert(item.mutation, pace, item.agentName); + return; + } + await this.performTypedSuggest(item.mutation, pace, item.agentName); + } + + /** + * Types a single-paragraph insert out chunk by chunk. Anchor resolution + * happens here (dequeue time), not when the mutation was enqueued, so a + * stale anchor is simply dropped — nothing is applied, and the caller + * (runPerformances) still deletes the row. + * + * Multi-paragraph markdown applies as one shot once it's this mutation's + * turn — only the single-paragraph case gets the typing effect. No doc + * mutation happens before the first tick's delay elapses, so a DO + * eviction before that point leaves nothing for eviction recovery to + * collide with. + */ + private async performTypedInsert( + mutation: Extract, + pace: "natural" | "fast", + agentName: string, + ): Promise { const { doc } = this.ensureInitialised(); - const resolved = resolveAnchor(doc, args.anchor); - if ("error" in resolved) { - return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + let index: number; + if (mutation.where === "append") { + index = getBlocks(doc).length; + } else { + if (!mutation.anchor) return; + const resolved = resolveAnchor(doc, mutation.anchor); + if ("error" in resolved) return; + index = mutation.where === "before" ? resolved.index : resolved.index + 1; } + if (mutation.markdown.includes("\n")) { + doc.transact(() => insertMarkdownBlocks(doc, index, mutation.markdown)); + return; + } + + const { cleanText, marks } = parseCriticMarkupToContent(mutation.markdown); + const ticks = chunkTyping(cleanText, pace); + + const el = new Y.XmlElement("paragraph"); + const ytext = new Y.XmlText(""); + el.insert(0, [ytext]); + + let typed = 0; + let inserted = false; + for (const tick of ticks) { + await sleep(tick.delayMs); + if (!inserted) { + doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); + inserted = true; + } + doc.transact(() => ytext.insert(typed, tick.chunk)); + typed += tick.chunk.length; + this.onPerformanceCursor(agentName, index); + } + + if (!inserted) { + // Empty text (e.g. a blank line) — nothing to type; insert the + // (empty) paragraph so the block structure still matches. + doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); + } else if (marks.length > 0) { + doc.transact(() => { + for (const mark of marks) { + ytext.format(mark.from, mark.to - mark.from, { [mark.type]: mark.attrs ?? {} }); + } + }); + } + } + + /** + * Types a suggestion's replacement text out chunk by chunk. The `find` + * text is marked as a critic deletion only once the first tick fires — + * mirroring performTypedInsert, so nothing is touched before that point + * and eviction recovery can safely re-run the whole mutation from + * scratch. + */ + private async performTypedSuggest( + mutation: Extract, + pace: "natural" | "fast", + agentName: string, + ): Promise { + const { doc } = this.ensureInitialised(); + + const resolved = resolveAnchor(doc, mutation.anchor); + if ("error" in resolved) return; + const frag = doc.getXmlFragment("default"); const el = frag.get(resolved.index); const ytext = el instanceof Y.XmlElement ? el.toArray().find((child): child is Y.XmlText => child instanceof Y.XmlText) : undefined; + if (!ytext) return; - const block = getBlocks(doc)[resolved.index]; - if (!ytext) { - return { error: { code: "find_not_matched", message: "Block has no text", snippet: block?.text ?? "" } }; + const cleanText = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); + const pos = cleanText.indexOf(mutation.find); + if (pos === -1) return; + + const ticks = chunkTyping(mutation.replacement, pace); + let typed = 0; + let marked = false; + for (const tick of ticks) { + await sleep(tick.delayMs); + if (!marked) { + doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); + marked = true; + } + doc.transact(() => ytext.insert(pos + mutation.find.length + typed, tick.chunk, { criticAddition: {} })); + typed += tick.chunk.length; + this.onPerformanceCursor(agentName, resolved.index); } - const cleanText = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); - const pos = cleanText.indexOf(args.find); - if (pos === -1) { - return { - error: { code: "find_not_matched", message: "Could not find text to suggest a change on", snippet: block.text }, - }; + if (!marked) { + // Empty replacement — still lay down the deletion mark. + doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); } + } - doc.transact(() => { - ytext.format(pos, args.find.length, { criticDeletion: {} }); - ytext.insert(pos + args.find.length, args.replacement, { criticAddition: {} }); - }); + /** + * Moves the agent's cursor/awareness to a block during a performance. + * No-op until Task 7 wires this up to real awareness state. + */ + private onPerformanceCursor(_agentName: string, _blockIndex: number): void { + // Intentionally empty — see Task 7. + } - return { ok: true }; + /** + * Applies a mutation's Yjs change directly, synchronously, in one + * transaction. Shared by the instant path (pace "instant", or no humans + * connected), eviction recovery, and the queue runner's handling of + * `replace` mutations (which have no typing animation of their own). + */ + private applyMutation(m: MutationPayload): { ok: true } | { error: AgentError } { + const { doc } = this.ensureInitialised(); + + switch (m.kind) { + case "insert": { + let index: number; + if (m.where === "append") { + index = getBlocks(doc).length; + } else { + if (!m.anchor) { + return { + error: { code: "stale_anchor", message: `An anchor is required for where: "${m.where}"` }, + }; + } + const resolved = resolveAnchor(doc, m.anchor); + if ("error" in resolved) { + return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + } + index = m.where === "before" ? resolved.index : resolved.index + 1; + } + + doc.transact(() => { + insertMarkdownBlocks(doc, index, m.markdown); + }); + + return { ok: true }; + } + + case "replace": { + const fromResolved = resolveAnchor(doc, m.from); + if ("error" in fromResolved) { + return { error: { code: fromResolved.error, message: "Anchor not found", snippet: fromResolved.snippet } }; + } + const toResolved = resolveAnchor(doc, m.to ?? m.from); + if ("error" in toResolved) { + return { error: { code: toResolved.error, message: "Anchor not found", snippet: toResolved.snippet } }; + } + + const fromIndex = fromResolved.index; + const toIndex = toResolved.index; + + if (toIndex < fromIndex) { + const snippet = getBlocks(doc) + .slice(0, 6) + .map((b) => `[b${b.index} ${b.hash}] ${b.text.slice(0, 60)}`) + .join("\n"); + return { + error: { + code: "stale_anchor", + message: `Anchor range resolved out of order: "to" (block ${toIndex}) is before "from" (block ${fromIndex}). Re-read the document and retry with fresh anchors.`, + snippet, + }, + }; + } + + doc.transact(() => { + deleteBlocks(doc, fromIndex, toIndex); + insertMarkdownBlocks(doc, fromIndex, m.markdown); + }); + + return { ok: true }; + } + + case "suggest": { + const resolved = resolveAnchor(doc, m.anchor); + if ("error" in resolved) { + return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + } + + const frag = doc.getXmlFragment("default"); + const el = frag.get(resolved.index); + const ytext = el instanceof Y.XmlElement + ? el.toArray().find((child): child is Y.XmlText => child instanceof Y.XmlText) + : undefined; + + const block = getBlocks(doc)[resolved.index]; + if (!ytext) { + return { error: { code: "find_not_matched", message: "Block has no text", snippet: block?.text ?? "" } }; + } + + const cleanText = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); + const pos = cleanText.indexOf(m.find); + if (pos === -1) { + return { + error: { code: "find_not_matched", message: "Could not find text to suggest a change on", snippet: block.text }, + }; + } + + doc.transact(() => { + ytext.format(pos, m.find.length, { criticDeletion: {} }); + ytext.insert(pos + m.find.length, m.replacement, { criticAddition: {} }); + }); + + return { ok: true }; + } + } } private broadcastBinary(message: WSMessage, excludeId: string) { diff --git a/app/lib/performance-chunks.ts b/app/lib/performance-chunks.ts new file mode 100644 index 00000000..b30a4c1e --- /dev/null +++ b/app/lib/performance-chunks.ts @@ -0,0 +1,64 @@ +export interface TypingTick { + chunk: string; + delayMs: number; +} + +/** Characters after which a sentence-pause is inserted, at "natural" pace. */ +const SENTENCE_ENDINGS = new Set([".", "!", "?", "\n"]); + +/** + * Splits `text` into a sequence of typing ticks — chunks of characters plus + * the delay before the *next* chunk — used to simulate an agent typing into + * the document instead of pasting it in one shot. + * + * - `"natural"`: 2-6 chars/tick, 30-80ms base delay; an extra 300-900ms + * pause is added after a tick ending in ".", "!", "?", or "\n". + * - `"fast"`: 8-16 chars/tick, 10-20ms delay; no sentence pauses. + * + * `rng` defaults to `Math.random` and is injectable so tests can produce + * deterministic output (e.g. `() => 0.5`). + */ +export function chunkTyping( + text: string, + pace: "natural" | "fast", + rng: () => number = Math.random, +): TypingTick[] { + const ticks: TypingTick[] = []; + + const [minChars, maxChars, minDelay, maxDelay] = + pace === "fast" ? [8, 16, 10, 20] : [2, 6, 30, 80]; + + let cursor = 0; + // Carried from a sentence-ending chunk onto the delay of the *next* + // tick, so the pause reads as "after the sentence, before typing on". + let extraDelayForNext = 0; + while (cursor < text.length) { + const size = Math.min( + minChars + Math.floor(rng() * (maxChars - minChars + 1)), + text.length - cursor, + ); + let chunk = text.slice(cursor, cursor + size); + + if (pace === "natural") { + // Force a chunk boundary right after a sentence-ending character so + // the pause can land cleanly between it and the next tick. + for (let i = 0; i < chunk.length; i++) { + if (SENTENCE_ENDINGS.has(chunk[i])) { + chunk = chunk.slice(0, i + 1); + break; + } + } + } + cursor += chunk.length; + + const delayMs = minDelay + Math.floor(rng() * (maxDelay - minDelay + 1)) + extraDelayForNext; + extraDelayForNext = 0; + if (pace === "natural" && SENTENCE_ENDINGS.has(chunk[chunk.length - 1])) { + extraDelayForNext = 300 + Math.floor(rng() * 601); + } + + ticks.push({ chunk, delayMs }); + } + + return ticks; +} diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 3b721ba4..2013011e 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -836,5 +836,126 @@ describe("DocumentAgent", () => { const after = await agent.agentRead(token); expect("markdown" in after && after.markdown).toBe(beforeMarkdown); }); + + /* ================================================================ */ + /* Performance engine (pacing) */ + /* ================================================================ */ + + describe("pacing", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("applies instantly when there are no human connections, even at natural pace", async () => { + const { agent, token } = await setup(["write"]); + const result = await agent.agentInsert(token, { + where: "append", + markdown: "Typed live.", + pace: "natural", + }); + expect(result).toEqual({ ok: true }); + + const read = await agent.agentRead(token); + expect("markdown" in read && read.markdown).toContain("Typed live."); + }); + + it("applies instantly regardless of pace when pace is 'instant'", async () => { + const { agent, token } = await setup(["write"]); + createConnection(); + const result = await agent.agentInsert(token, { + where: "append", + markdown: "Pasted in.", + pace: "instant", + }); + expect(result).toEqual({ ok: true }); + + const read = await agent.agentRead(token); + expect("markdown" in read && read.markdown).toContain("Pasted in."); + }); + + it("enqueues and types out a natural-pace insert while a human is connected", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + createConnection(); + + const result = await agent.agentInsert(token, { + where: "append", + markdown: "Hi. Yo", + pace: "natural", + }); + expect(result).toEqual({ ok: true }); + + // The first typing tick runs synchronously before agentInsert + // resolves, so some content is present — but not all of it yet. + const afterFirstTick = await agent.agentRead(token); + const partial = "markdown" in afterFirstTick ? afterFirstTick.markdown : ""; + expect(partial).not.toContain("Hi. Yo"); + + await vi.runAllTimersAsync(); + + const after = await agent.agentRead(token); + expect("markdown" in after && after.markdown).toContain("Hi. Yo"); + }); + + it("applies a leftover queued mutation instantly on restart (eviction recovery)", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + createConnection(); + + await agent.agentInsert(token, { + where: "append", + markdown: "Recovered text.", + pace: "natural", + }); + + // The mutation is mid-flight: first chunk typed, remaining ticks + // still pending on the (fake) clock, row still persisted. Simulate + // a DO eviction + restart by constructing a fresh agent instance + // over the same underlying SQL store, without ever advancing time. + const agent2 = new DocumentAgent({} as never, {} as never); + const read = await agent2.agentRead(token); + expect("markdown" in read && read.markdown).toContain("Recovered text."); + }); + + it("drops a queued mutation whose anchor goes stale before its turn", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + createConnection(); + + const before = await agent.agentRead(token); + const anchor0 = ("blocks" in before ? before.blocks : [])[0].anchor; // "# Title" + + // Busy the runner with a slow natural-pace insert. + await agent.agentInsert(token, { + where: "append", + markdown: "Long enough text to take a few typing ticks.", + pace: "natural", + }); + + // Queue a replace behind it, targeting the still-fresh anchor0. + const queuedReplace = agent.agentReplace(token, { + from: anchor0, + markdown: "Replaced!", + pace: "natural", + }); + expect(await queuedReplace).toEqual({ ok: true }); + + // An instant edit invalidates anchor0 before the queued replace + // gets its turn. + const instantEdit = await agent.agentReplace(token, { + from: anchor0, + markdown: "Changed first!", + pace: "instant", + }); + expect(instantEdit).toEqual({ ok: true }); + + await vi.runAllTimersAsync(); + + const after = await agent.agentRead(token); + const markdown = "markdown" in after ? after.markdown : ""; + expect(markdown).toContain("Changed first!"); + expect(markdown).not.toContain("Replaced!"); + }); + }); }); }); diff --git a/tests/unit/lib/performance-chunks.test.ts b/tests/unit/lib/performance-chunks.test.ts new file mode 100644 index 00000000..8004e142 --- /dev/null +++ b/tests/unit/lib/performance-chunks.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { chunkTyping } from "~/lib/performance-chunks"; + +describe("chunkTyping", () => { + it("covers the whole text in order", () => { + const ticks = chunkTyping("Hello world. Bye.", "natural", () => 0.5); + expect(ticks.map((t) => t.chunk).join("")).toBe("Hello world. Bye."); + }); + + it("pauses after sentence ends", () => { + const ticks = chunkTyping("Hi. Yo", "natural", () => 0.5); + const afterDot = ticks.find((t) => t.chunk.startsWith(" Yo") || t.chunk.startsWith("Yo")); + expect(afterDot!.delayMs).toBeGreaterThanOrEqual(300); + }); + + it("fast pace uses bigger chunks", () => { + expect(chunkTyping("x".repeat(100), "fast", () => 0.5).length) + .toBeLessThan(chunkTyping("x".repeat(100), "natural", () => 0.5).length); + }); + + it("returns an empty array for empty text", () => { + expect(chunkTyping("", "natural", () => 0.5)).toEqual([]); + }); + + it("natural pace ticks fall within the 2-6 char, 30-80ms base range", () => { + const ticks = chunkTyping("abcdefghij", "natural", () => 0); + for (const tick of ticks) { + expect(tick.chunk.length).toBeGreaterThanOrEqual(1); + expect(tick.chunk.length).toBeLessThanOrEqual(6); + expect(tick.delayMs).toBeGreaterThanOrEqual(30); + } + }); + + it("fast pace ticks fall within the 8-16 char, 10-20ms base range", () => { + const ticks = chunkTyping("abcdefghijklmnopqrstuvwxyz", "fast", () => 0); + for (const tick of ticks) { + expect(tick.chunk.length).toBeGreaterThanOrEqual(1); + expect(tick.chunk.length).toBeLessThanOrEqual(16); + expect(tick.delayMs).toBeGreaterThanOrEqual(10); + expect(tick.delayMs).toBeLessThanOrEqual(20); + } + }); + + it("is deterministic for a fixed rng", () => { + const a = chunkTyping("Hello world. Bye.", "natural", () => 0.5); + const b = chunkTyping("Hello world. Bye.", "natural", () => 0.5); + expect(a).toEqual(b); + }); +}); From bdb10cfd52e9807aa71fabc146fb3b8957ff8e5e Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 10:16:48 -0700 Subject: [PATCH 15/83] Fix stale index/position and mid-typing eviction duplication in performance engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found two Important issues in the Task 6 performance queue: 1. performTypedInsert/performTypedSuggest captured a block index (or text position) once and kept reusing it across every `await sleep(...)` tick, so a concurrent pace:"instant" mutation (which bypasses the queue) could shift the document under them, landing later writes at a stale spot. Fixed by claiming the target slot synchronously (zero yield before the first write), then tracking the write position from there on via a Y.RelativePosition, which stays correct across concurrent structural edits and aborts cleanly if it stops resolving. 2. Eviction recovery re-applied a queued mutation's full original text even if some ticks had already landed, duplicating what was typed. Fixed by deleting a performance's `performances` row at the moment its first write lands (the same synchronous-claim moment from fix 1) instead of at completion — from that instant the typed content is already part of the Yjs doc and persisted normally, so an eviction mid-typing now loses only the untyped tail instead of duplicating anything. Added a covering test that reproduces fix 1 (an anchored typed insert vs. a concurrent instant insert on the same anchor) — verified it fails against the pre-fix code and passes against the fix. Redesigned the eviction recovery test to use two queued mutations so it genuinely exercises the pre-first-write case under the new claim-then-delete semantics. Tightened the natural-pace typing test's partial-content assertion to a bounded timer advance instead of an assertion that also passes for a fully atomic implementation. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 171 +++++++++++------- .../integration/agents/document-agent.test.ts | 145 ++++++++++++++- 2 files changed, 244 insertions(+), 72 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index ccbb68a9..4c8f5742 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -160,10 +160,12 @@ class DocumentAgent extends Agent { `; }); - // Eviction recovery: any row still in `performances` means the DO was - // evicted mid-performance (or between enqueue and its turn). There's no - // one left mid-typing to watch it happen, so apply each leftover - // mutation instantly, in the order it was queued, and drop the row. + // Eviction recovery: a row still in `performances` means the DO was + // evicted before that mutation ever touched the doc — the row is + // deleted the instant a performance's first write lands (see + // performTypedInsert/performTypedSuggest), so anything still here was + // never applied at all. Apply each leftover mutation instantly, in the + // order it was queued, and drop the row. const leftover = this.sql` SELECT * FROM performances ORDER BY id ASC `; @@ -714,23 +716,29 @@ class DocumentAgent extends Agent { } /** - * Drains the performance queue one mutation at a time, in FIFO order, - * removing each row from `performances` once it's fully applied (or - * dropped as stale). Runs for as long as the DO stays live; if it's - * evicted mid-queue, ensureInitialised()'s recovery step picks up - * whatever rows are left on the next wake-up. + * Drains the performance queue one mutation at a time, in FIFO order. + * Runs for as long as the DO stays live; if it's evicted mid-queue, + * ensureInitialised()'s recovery step picks up whatever rows are left on + * the next wake-up. Each performX method below is responsible for + * deleting its own `performances` row at the right moment — see + * performTypedInsert/performTypedSuggest for why that isn't simply "when + * this function returns". */ private async runPerformances(): Promise { this.isPerforming = true; while (this.performanceQueue.length > 0) { const item = this.performanceQueue[0]; await this.performQueuedMutation(item); - this.sql`DELETE FROM performances WHERE id = ${item.id}`; this.performanceQueue.shift(); } this.isPerforming = false; } + /** Deletes a performance's row. Safe to call more than once (no-op the second time). */ + private deletePerformanceRow(id: number): void { + this.sql`DELETE FROM performances WHERE id = ${id}`; + } + /** * Applies one queued mutation. `replace` has no meaningful "typing" * animation (it's a delete-and-insert), so it applies atomically as soon @@ -742,74 +750,100 @@ class DocumentAgent extends Agent { if (item.mutation.kind === "replace") { this.applyMutation(item.mutation); + this.deletePerformanceRow(item.id); return; } if (item.mutation.kind === "insert") { - await this.performTypedInsert(item.mutation, pace, item.agentName); + await this.performTypedInsert(item, pace); return; } - await this.performTypedSuggest(item.mutation, pace, item.agentName); + await this.performTypedSuggest(item, pace); } /** * Types a single-paragraph insert out chunk by chunk. Anchor resolution * happens here (dequeue time), not when the mutation was enqueued, so a - * stale anchor is simply dropped — nothing is applied, and the caller - * (runPerformances) still deletes the row. + * stale anchor is simply dropped. * * Multi-paragraph markdown applies as one shot once it's this mutation's - * turn — only the single-paragraph case gets the typing effect. No doc - * mutation happens before the first tick's delay elapses, so a DO - * eviction before that point leaves nothing for eviction recovery to - * collide with. + * turn — only the single-paragraph case gets the typing effect. + * + * Concurrency: the target block index is only trustworthy up to the + * point we last touched the doc without yielding. So the empty + * paragraph is inserted at `index` *synchronously*, before the first + * `await sleep(...)` — claiming its slot before any concurrent instant + * mutation gets a chance to run and shift indices out from under us. + * From there on, characters are typed in via a Y.RelativePosition bound + * to that paragraph's text, which stays correct regardless of what else + * happens to the surrounding document structure; if the position can no + * longer be resolved (e.g. the paragraph itself was deleted by a + * concurrent edit), typing stops cleanly instead of writing into the + * wrong place. + * + * The `performances` row is deleted the moment the slot is claimed, not + * when typing finishes: from that instant, whatever's been typed is + * already part of the Yjs document and persisted the normal way (the + * doc_state update hook), so a DO eviction mid-typing loses only the + * as-yet-untyped tail rather than risking a duplicate re-application on + * recovery. An eviction *before* the slot is claimed leaves the row + * intact, and ensureInitialised() applies the whole mutation instantly. */ - private async performTypedInsert( - mutation: Extract, - pace: "natural" | "fast", - agentName: string, - ): Promise { + private async performTypedInsert(item: PendingMutation, pace: "natural" | "fast"): Promise { + const mutation = item.mutation as Extract; const { doc } = this.ensureInitialised(); let index: number; if (mutation.where === "append") { index = getBlocks(doc).length; } else { - if (!mutation.anchor) return; + if (!mutation.anchor) { + this.deletePerformanceRow(item.id); + return; + } const resolved = resolveAnchor(doc, mutation.anchor); - if ("error" in resolved) return; + if ("error" in resolved) { + this.deletePerformanceRow(item.id); + return; + } index = mutation.where === "before" ? resolved.index : resolved.index + 1; } if (mutation.markdown.includes("\n")) { doc.transact(() => insertMarkdownBlocks(doc, index, mutation.markdown)); + this.deletePerformanceRow(item.id); return; } const { cleanText, marks } = parseCriticMarkupToContent(mutation.markdown); const ticks = chunkTyping(cleanText, pace); + // Claim the slot now, synchronously — see the doc comment above. const el = new Y.XmlElement("paragraph"); const ytext = new Y.XmlText(""); el.insert(0, [ytext]); + doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); + this.deletePerformanceRow(item.id); let typed = 0; - let inserted = false; + let relPos = Y.createRelativePositionFromTypeIndex(ytext, 0); for (const tick of ticks) { await sleep(tick.delayMs); - if (!inserted) { - doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); - inserted = true; + const { doc: liveDoc } = this.ensureInitialised(); + const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, liveDoc); + if (!absPos || absPos.type !== ytext) { + // The paragraph (or its text) is gone — nothing sane left to type into. + return; } - doc.transact(() => ytext.insert(typed, tick.chunk)); + doc.transact(() => ytext.insert(absPos.index, tick.chunk)); typed += tick.chunk.length; - this.onPerformanceCursor(agentName, index); + relPos = Y.createRelativePositionFromTypeIndex(ytext, absPos.index + tick.chunk.length); + this.onPerformanceCursor(item.agentName, index); } - if (!inserted) { - // Empty text (e.g. a blank line) — nothing to type; insert the - // (empty) paragraph so the block structure still matches. - doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); - } else if (marks.length > 0) { + // Only apply the original CriticMarkup marks if the full text landed + // undisturbed — if typing was cut short above, mark offsets computed + // against the original text no longer mean anything. + if (marks.length > 0 && typed === cleanText.length) { doc.transact(() => { for (const mark of marks) { ytext.format(mark.from, mark.to - mark.from, { [mark.type]: mark.attrs ?? {} }); @@ -819,50 +853,63 @@ class DocumentAgent extends Agent { } /** - * Types a suggestion's replacement text out chunk by chunk. The `find` - * text is marked as a critic deletion only once the first tick fires — - * mirroring performTypedInsert, so nothing is touched before that point - * and eviction recovery can safely re-run the whole mutation from - * scratch. + * Types a suggestion's replacement text out chunk by chunk. + * + * `find`'s position is resolved and immediately (synchronously, no + * `await` in between) marked as a critic deletion — that's the "claim" + * moment, matching performTypedInsert, and it's what makes "re-verify + * `find` is still there before marking" automatic: nothing can run + * between resolving `pos` and writing the mark. The `performances` row + * is deleted at that same moment, for the same eviction-safety reason as + * performTypedInsert. The replacement text is then typed in via a + * Y.RelativePosition anchored just after the deleted `find` text, so a + * concurrent edit elsewhere can't make it land in the wrong place; + * typing stops cleanly if that position stops resolving. */ - private async performTypedSuggest( - mutation: Extract, - pace: "natural" | "fast", - agentName: string, - ): Promise { + private async performTypedSuggest(item: PendingMutation, pace: "natural" | "fast"): Promise { + const mutation = item.mutation as Extract; const { doc } = this.ensureInitialised(); const resolved = resolveAnchor(doc, mutation.anchor); - if ("error" in resolved) return; + if ("error" in resolved) { + this.deletePerformanceRow(item.id); + return; + } const frag = doc.getXmlFragment("default"); const el = frag.get(resolved.index); const ytext = el instanceof Y.XmlElement ? el.toArray().find((child): child is Y.XmlText => child instanceof Y.XmlText) : undefined; - if (!ytext) return; + if (!ytext) { + this.deletePerformanceRow(item.id); + return; + } const cleanText = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); const pos = cleanText.indexOf(mutation.find); - if (pos === -1) return; + if (pos === -1) { + this.deletePerformanceRow(item.id); + return; + } + + // Claim the slot now, synchronously — see the doc comment above. + doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); + let relPos = Y.createRelativePositionFromTypeIndex(ytext, pos + mutation.find.length); + this.deletePerformanceRow(item.id); const ticks = chunkTyping(mutation.replacement, pace); - let typed = 0; - let marked = false; for (const tick of ticks) { await sleep(tick.delayMs); - if (!marked) { - doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); - marked = true; + const { doc: liveDoc } = this.ensureInitialised(); + const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, liveDoc); + if (!absPos || absPos.type !== ytext) { + // The block (or its text) is gone — nothing sane left to type into. + return; } - doc.transact(() => ytext.insert(pos + mutation.find.length + typed, tick.chunk, { criticAddition: {} })); - typed += tick.chunk.length; - this.onPerformanceCursor(agentName, resolved.index); - } - - if (!marked) { - // Empty replacement — still lay down the deletion mark. - doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); + doc.transact(() => ytext.insert(absPos.index, tick.chunk, { criticAddition: {} })); + relPos = Y.createRelativePositionFromTypeIndex(ytext, absPos.index + tick.chunk.length); + this.onPerformanceCursor(item.agentName, resolved.index); } } diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 2013011e..299625ff 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -878,23 +878,40 @@ describe("DocumentAgent", () => { const { agent, token } = await setup(["write"]); createConnection(); + // No punctuation, so no sentence pauses — keeps the timing math + // below simple. 78 chars. + const fullText = "abcdefghijklmnopqrstuvwxyz".repeat(3); const result = await agent.agentInsert(token, { where: "append", - markdown: "Hi. Yo", + markdown: fullText, pace: "natural", }); expect(result).toEqual({ ok: true }); - // The first typing tick runs synchronously before agentInsert - // resolves, so some content is present — but not all of it yet. + // The insert's slot (an empty paragraph) is claimed synchronously + // before agentInsert resolves, but nothing has been typed into it + // yet — the first character requires the first tick's delay to + // elapse. + const beforeAnyTick = await agent.agentRead(token); + const blocksBefore = "blocks" in beforeAnyTick ? beforeAnyTick.blocks : []; + expect(blocksBefore[3]?.text ?? "").toBe(""); + + // Advance past at least the first tick (minimum natural-pace delay + // is 30ms), but nowhere near enough for the fastest possible full + // typing (78 chars / 6 chars-per-tick max * 30ms-per-tick min = + // 390ms) — so this is genuinely partial, not a fluke of timing. + await vi.advanceTimersByTimeAsync(100); + const afterFirstTick = await agent.agentRead(token); - const partial = "markdown" in afterFirstTick ? afterFirstTick.markdown : ""; - expect(partial).not.toContain("Hi. Yo"); + const partialBlock = ("blocks" in afterFirstTick ? afterFirstTick.blocks : [])[3]; + const partialLength = partialBlock?.text.length ?? 0; + expect(partialLength).toBeGreaterThan(0); + expect(partialLength).toBeLessThan(fullText.length); await vi.runAllTimersAsync(); const after = await agent.agentRead(token); - expect("markdown" in after && after.markdown).toContain("Hi. Yo"); + expect("markdown" in after && after.markdown).toContain(fullText); }); it("applies a leftover queued mutation instantly on restart (eviction recovery)", async () => { @@ -902,21 +919,129 @@ describe("DocumentAgent", () => { const { agent, token } = await setup(["write"]); createConnection(); + // Busy the runner with a slow first mutation so the second one's + // turn never comes — its row is claimed (and deleted) the instant + // its own typing starts, which happens synchronously as part of + // *this* call. + await agent.agentInsert(token, { + where: "append", + markdown: "abcdefghijklmnopqrstuvwxyz".repeat(3), + pace: "natural", + }); + + // This second mutation is still sitting behind the first in the + // queue, completely untouched — pre-first-write, so its row is + // still fully intact in `performances`. await agent.agentInsert(token, { where: "append", markdown: "Recovered text.", pace: "natural", }); - // The mutation is mid-flight: first chunk typed, remaining ticks - // still pending on the (fake) clock, row still persisted. Simulate - // a DO eviction + restart by constructing a fresh agent instance - // over the same underlying SQL store, without ever advancing time. + // Simulate a DO eviction + restart by constructing a fresh agent + // instance over the same underlying SQL store, without ever + // advancing time (so the busy first mutation never finishes, and + // the second mutation's row is never touched by the runner). const agent2 = new DocumentAgent({} as never, {} as never); const read = await agent2.agentRead(token); expect("markdown" in read && read.markdown).toContain("Recovered text."); }); + it("keeps both texts present exactly once, in sane positions, despite a concurrent instant append", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + createConnection(); + + const minted2 = await agent.mintAgentToken({ name: "bot2", capabilities: ["write"] }); + const token2 = (minted2 as { token: string }).token; + + // Starts typing "Slow typed line" at natural pace — claims its + // block slot synchronously, before any ticks fire. + const pacedResult = await agent.agentInsert(token, { + where: "append", + markdown: "Slow typed line", + pace: "natural", + }); + expect(pacedResult).toEqual({ ok: true }); + + // A second agent's instant append lands while the first is still + // mid-typing. + const instantResult = await agent.agentInsert(token2, { + where: "append", + markdown: "Instant line", + pace: "instant", + }); + expect(instantResult).toEqual({ ok: true }); + + await vi.runAllTimersAsync(); + + const after = await agent.agentRead(token); + const markdown = "markdown" in after ? after.markdown : ""; + const blocks = "blocks" in after ? after.blocks : []; + const texts = blocks.map((b) => b.text); + + expect(markdown.match(/Slow typed line/g)).toHaveLength(1); + expect(markdown.match(/Instant line/g)).toHaveLength(1); + // The typed insert claimed its slot first, so the instant append + // lands after it instead of clobbering/reordering it. + expect(texts.indexOf("Slow typed line")).toBeLessThan(texts.indexOf("Instant line")); + }); + + it("keeps an anchored typed insert on the correct side of its anchor despite a concurrent instant insert before it", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + createConnection(); + + const minted2 = await agent.mintAgentToken({ name: "bot2", capabilities: ["write"] }); + const token2 = (minted2 as { token: string }).token; + + const before = await agent.agentRead(token); + const anchor0 = ("blocks" in before ? before.blocks : [])[0].anchor; // "# Title" + + // Starts typing "Slow typed line" right after the title, at + // natural pace. This claims its slot (right after block 0) + // synchronously, before any ticks fire — the block index it + // resolved to is only valid up to that point. + const pacedResult = await agent.agentInsert(token, { + where: "after", + anchor: anchor0, + markdown: "Slow typed line", + pace: "natural", + }); + expect(pacedResult).toEqual({ ok: true }); + + // A second agent inserts *before* the same anchor, instantly, while + // the first is still mid-typing. If the typed insert's write used + // its originally-resolved raw index instead of tracking the + // paragraph itself, this would land the typed text *before* the + // title it was supposed to follow. + const instantResult = await agent.agentInsert(token2, { + where: "before", + anchor: anchor0, + markdown: "Preamble", + pace: "instant", + }); + expect(instantResult).toEqual({ ok: true }); + + await vi.runAllTimersAsync(); + + const after = await agent.agentRead(token); + const markdown = "markdown" in after ? after.markdown : ""; + const blocks = "blocks" in after ? after.blocks : []; + const texts = blocks.map((b) => b.text); + + expect(markdown.match(/Slow typed line/g)).toHaveLength(1); + expect(markdown.match(/Preamble/g)).toHaveLength(1); + const titleIndex = texts.indexOf("# Title"); + const preambleIndex = texts.indexOf("Preamble"); + const slowIndex = texts.indexOf("Slow typed line"); + expect(preambleIndex).toBeLessThan(titleIndex); + // "Slow typed line" was requested as "after # Title" — it must + // stay after it even though "Preamble" was inserted before the + // title while it was still mid-flight. + expect(slowIndex).toBeGreaterThan(titleIndex); + }); + it("drops a queued mutation whose anchor goes stale before its turn", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const { agent, token } = await setup(["write"]); From 3b71b2f12628fe69483a8ecf85b49b5c770edc05 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 10:30:25 -0700 Subject: [PATCH 16/83] Add synthetic agent presence to awareness Gives agents a synthetic Yjs awareness client so humans see them in the presence stack and caret while they work. app/lib/agent-awareness.ts hand-encodes MSG_AWARENESS frames for a stable per-name synthetic clientId; DocumentAgent tracks join/leave/idle state in a new agentPresence map, broadcasting on every change and replaying it to late joiners in onConnect. onPerformanceCursor now sources the caret position from the performance engine's live Y.RelativePosition tracking (the text node + current offset) instead of the frozen block index it was called with before, so it stays correct under concurrent edits. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 161 +++++++++++++++++- app/app.css | 10 ++ app/components/Editor.tsx | 7 + app/lib/agent-awareness.ts | 64 +++++++ .../integration/agents/document-agent.test.ts | 156 +++++++++++++++++ tests/unit/lib/agent-awareness.test.ts | 79 +++++++++ 6 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 app/lib/agent-awareness.ts create mode 100644 tests/unit/lib/agent-awareness.test.ts diff --git a/agents/document.ts b/agents/document.ts index 4c8f5742..8f988e78 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -18,8 +18,12 @@ import { generateAgentToken, hashToken } from "../app/lib/agent-tokens"; import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteBlocks } from "../app/lib/y-markdown"; import { parseCriticMarkupToContent } from "../app/lib/critic-parser"; import { chunkTyping } from "../app/lib/performance-chunks"; +import { encodeAgentAwareness, agentClientId, type AgentPresenceState } from "../app/lib/agent-awareness"; import type { ThreadData } from "../app/shared/types"; +/** How long an agent can go without a join/performance before its presence is auto-removed. */ +const AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + /** * Durable Objects SQLite accepts Uint8Array for BLOB columns via the * template literal API, but the type signature expects string. This @@ -104,6 +108,21 @@ class DocumentAgent extends Agent { */ private nextPerformanceId = 1; + /** + * Synthetic awareness presence for agents, keyed by agent name. `clock` + * is monotonically increasing (never reset) because `clientId` is stable + * across join/leave/idle cycles for a given agent name — a browser + * client's Awareness only accepts an update whose clock is strictly + * greater than the last one it saw for that clientId (or an equal clock + * that carries a null state), so restarting the clock at 1 after a leave + * would make later updates silently ignored by anyone who saw the higher + * clock before. `state: null` means "currently absent" (left or idled + * out) but the entry is kept so the clock keeps counting up. + */ + private agentPresence = new Map(); + /** Per-agent 5-minute idle timer, reset on every join/performance-cursor update. */ + private agentIdleTimers = new Map>(); + private ensureInitialised(): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { if (this.doc && this.awareness) { return { doc: this.doc, awareness: this.awareness }; @@ -203,6 +222,13 @@ class DocumentAgent extends Agent { encoding.writeVarUint8Array(awarenessEncoder, update); connection.send(encoding.toUint8Array(awarenessEncoder)); } + + // Replay current agent presence so a late joiner sees resident agents. + for (const presence of this.agentPresence.values()) { + if (presence.state) { + connection.send(encodeAgentAwareness(presence.clientId, presence.clock, presence.state)); + } + } } async onMessage(connection: Connection, message: WSMessage) { @@ -279,6 +305,13 @@ class DocumentAgent extends Agent { this.sql`DELETE FROM performances`; this.performanceQueue = []; this.isPerforming = false; + // Agent presence belongs to a document that no longer exists — drop it + // and cancel every pending idle timer along with it. + for (const timer of this.agentIdleTimers.values()) { + clearTimeout(timer); + } + this.agentIdleTimers.clear(); + this.agentPresence.clear(); // Close all active WebSocket connections for (const conn of this.getConnections()) { conn.close(1000, "Document expired"); @@ -662,6 +695,84 @@ class DocumentAgent extends Agent { }); } + /** + * Marks an agent present in awareness (visible in the presence stack and, + * once it performs a mutation, as a caret) and (re)starts its 5-minute + * idle timer. Any valid token may join — presence is not a capability. + */ + async agentJoin(token: string, status?: string): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token); + if ("error" in verified) return verified; + + const { name, color } = verified.entry; + this.setAgentPresence(name, { + user: { name, color, isAgent: true }, + ...(status !== undefined ? { status } : {}), + }); + this.resetAgentIdleTimer(name); + + return { ok: true }; + } + + /** + * Removes an agent's presence immediately (broadcasts a null state) and + * cancels its idle timer. The agent's token stays valid — leaving is + * purely an awareness-visibility signal, not a revocation. + */ + async agentLeave(token: string): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token); + if ("error" in verified) return verified; + + this.clearAgentIdleTimer(verified.entry.name); + this.setAgentPresence(verified.entry.name, null); + + return { ok: true }; + } + + /** + * Records and broadcasts a synthetic client's awareness state to every + * connection. Bumps that agent's clock (see the `agentPresence` field + * doc comment for why it never resets) regardless of whether `state` is + * a real presence or `null` (removal). + */ + private setAgentPresence(name: string, state: AgentPresenceState | null): void { + const existing = this.agentPresence.get(name); + const clientId = existing?.clientId ?? agentClientId(name); + const clock = (existing?.clock ?? 0) + 1; + this.agentPresence.set(name, { clientId, clock, state }); + this.broadcastAgentPresence(clientId, clock, state); + } + + /** Sends a hand-encoded MSG_AWARENESS frame to every connected client. */ + private broadcastAgentPresence(clientId: number, clock: number, state: AgentPresenceState | null): void { + const frame = encodeAgentAwareness(clientId, clock, state); + for (const conn of this.getConnections()) { + conn.send(frame); + } + } + + /** + * (Re)starts an agent's 5-minute idle timer. Called on join and on every + * performance-cursor update; firing removes the agent's presence (a + * broadcast null state) without touching its token. + */ + private resetAgentIdleTimer(name: string): void { + this.clearAgentIdleTimer(name); + const timer = setTimeout(() => { + this.agentIdleTimers.delete(name); + this.setAgentPresence(name, null); + }, AGENT_IDLE_TIMEOUT_MS); + this.agentIdleTimers.set(name, timer); + } + + private clearAgentIdleTimer(name: string): void { + const timer = this.agentIdleTimers.get(name); + if (timer) { + clearTimeout(timer); + this.agentIdleTimers.delete(name); + } + } + /** * Decides whether a mutation is applied synchronously or handed to the * performance queue. `pace: "instant"` (the default, for backward @@ -836,8 +947,9 @@ class DocumentAgent extends Agent { } doc.transact(() => ytext.insert(absPos.index, tick.chunk)); typed += tick.chunk.length; - relPos = Y.createRelativePositionFromTypeIndex(ytext, absPos.index + tick.chunk.length); - this.onPerformanceCursor(item.agentName, index); + const caretOffset = absPos.index + tick.chunk.length; + relPos = Y.createRelativePositionFromTypeIndex(ytext, caretOffset); + this.onPerformanceCursor(item.agentName, ytext, caretOffset); } // Only apply the original CriticMarkup marks if the full text landed @@ -908,17 +1020,50 @@ class DocumentAgent extends Agent { return; } doc.transact(() => ytext.insert(absPos.index, tick.chunk, { criticAddition: {} })); - relPos = Y.createRelativePositionFromTypeIndex(ytext, absPos.index + tick.chunk.length); - this.onPerformanceCursor(item.agentName, resolved.index); + const caretOffset = absPos.index + tick.chunk.length; + relPos = Y.createRelativePositionFromTypeIndex(ytext, caretOffset); + this.onPerformanceCursor(item.agentName, ytext, caretOffset); } } /** - * Moves the agent's cursor/awareness to a block during a performance. - * No-op until Task 7 wires this up to real awareness state. + * Moves an agent's caret to its live typing position during a + * performance. Takes the `Y.XmlText` node and offset being typed into + * *right now* rather than a block index: a block index resolved when the + * performance started goes stale the moment any concurrent edit shifts + * blocks around it (see the eviction/concurrency notes on + * performTypedInsert/performTypedSuggest above), whereas a fresh + * `Y.RelativePosition` built from the live text node at the moment of + * each tick always resolves to the right place regardless of what else + * has happened to the document structure. + * + * Builds the presence state itself (rather than going through + * `agentJoin`) because a performing agent may never have explicitly + * joined; on first cursor update for such an agent this looks its + * name/color up from the roster instead of failing silently. */ - private onPerformanceCursor(_agentName: string, _blockIndex: number): void { - // Intentionally empty — see Task 7. + private onPerformanceCursor(agentName: string, ytext: Y.XmlText, offset: number): void { + const relPos = Y.createRelativePositionFromTypeIndex(ytext, offset); + // Round-trip through JSON to strip the class instance down to the plain + // object y-tiptap's cursor plugin expects (and that JSON.stringify in + // encodeAgentAwareness will produce anyway) — see AgentPresenceState's + // doc comment for the exact shape. + const posJson = JSON.parse(JSON.stringify(Y.relativePositionToJSON(relPos))) as unknown; + const cursor = { anchor: posJson, head: posJson }; + + const existing = this.agentPresence.get(agentName); + const status = existing?.state?.status; + let user = existing?.state?.user; + if (!user) { + const rows = this.sql<{ name: string; color: string }>` + SELECT name, color FROM agent_tokens WHERE name = ${agentName} + `; + if (rows.length === 0) return; // unknown agent — nothing sane to show + user = { name: rows[0].name, color: rows[0].color, isAgent: true }; + } + + this.setAgentPresence(agentName, { user, ...(status !== undefined ? { status } : {}), cursor }); + this.resetAgentIdleTimer(agentName); } /** diff --git a/app/app.css b/app/app.css index 1f5921cd..ac4f0a3f 100644 --- a/app/app.css +++ b/app/app.css @@ -69,6 +69,16 @@ body { pointer-events: none; } +.tiptap .collaboration-cursor__badge { + margin-left: 0.3em; + padding: 0 0.25em; + font-size: 0.65em; + font-weight: 700; + letter-spacing: 0.02em; + border-radius: 2px; + background-color: rgb(255 255 255 / 35%); +} + /* Markdown decoration classes */ .md-bold { font-weight: 700; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 25c44e30..f7c8347e 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -182,6 +182,13 @@ function renderCaret(user: Record) { label.setAttribute("style", `background-color: ${user.color}`); label.insertBefore(document.createTextNode(user.name as string), null); + if (user.isAgent) { + const badge = document.createElement("span"); + badge.classList.add("collaboration-cursor__badge"); + badge.insertBefore(document.createTextNode("AI"), null); + label.insertBefore(badge, null); + } + cursor.insertBefore(label, null); return cursor; } diff --git a/app/lib/agent-awareness.ts b/app/lib/agent-awareness.ts new file mode 100644 index 00000000..25465d98 --- /dev/null +++ b/app/lib/agent-awareness.ts @@ -0,0 +1,64 @@ +import * as encoding from "lib0/encoding"; +import { MSG_AWARENESS } from "~/shared/constants"; +import { blockHash } from "~/shared/agent-protocol"; + +/** + * Presence state for a synthetic (agent) awareness client. Mirrors the + * `{ user, cursor }` shape human clients write via + * `awareness.setLocalStateField`, so `@tiptap/extension-collaboration-caret` + * (via `@tiptap/y-tiptap`'s `yCursorPlugin`) renders agents the same way it + * renders humans. `cursor`, when present, must be + * `{ anchor: Y.RelativePositionJSON, head: Y.RelativePositionJSON }` — see + * `y-tiptap`'s cursor plugin, which decodes both fields with + * `Y.createRelativePositionFromJSON`. + */ +export interface AgentPresenceState { + user: { name: string; color: string; isAgent: true }; + status?: string; + cursor?: unknown; +} + +/** + * Derives a stable synthetic Yjs awareness clientId from an agent's name. + * Agents have no real Yjs client of their own (no Y.Doc, no random + * clientID) — this makes reconnects and repeated join/leave cycles for the + * same agent name resolve to the same clientId instead of a fresh random + * one each time. `>>> 1` keeps the result a positive 31-bit int (well clear + * of sign-bit weirdness); the id is forced non-zero because 0 has no + * special meaning here but is worth avoiding as a footgun for equality + * checks against "no client" sentinels. + */ +export function agentClientId(name: string): number { + const id = parseInt(blockHash(name), 16) >>> 1; + return id === 0 ? 1 : id; +} + +/** + * Hand-encodes a complete MSG_AWARENESS websocket frame carrying exactly + * one synthetic client's state. Matches the wire format + * `y-protocols/awareness`'s `encodeAwarenessUpdate` + the MSG_AWARENESS + * envelope produce, byte for byte, so any real `Awareness` instance (a + * browser client) can decode it with `applyAwarenessUpdate` without any + * special-casing. Pure lib0 encoding only — agents have no `Y.Doc` or + * `Awareness` instance to encode from, so this can't reuse those helpers + * directly. + * + * Frame: varUint(MSG_AWARENESS), varUint8Array(update) + * Update (1 entry): varUint(1), varUint(clientId), varUint(clock), varString(JSON state | "null") + */ +export function encodeAgentAwareness( + clientId: number, + clock: number, + state: AgentPresenceState | null, +): Uint8Array { + const updateEncoder = encoding.createEncoder(); + encoding.writeVarUint(updateEncoder, 1); // one entry + encoding.writeVarUint(updateEncoder, clientId); + encoding.writeVarUint(updateEncoder, clock); + encoding.writeVarString(updateEncoder, JSON.stringify(state)); + + const frameEncoder = encoding.createEncoder(); + encoding.writeVarUint(frameEncoder, MSG_AWARENESS); + encoding.writeVarUint8Array(frameEncoder, encoding.toUint8Array(updateEncoder)); + return encoding.toUint8Array(frameEncoder); +} diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 299625ff..b5136728 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -1083,4 +1083,160 @@ describe("DocumentAgent", () => { }); }); }); + + /* ================================================================ */ + /* Agent presence in awareness */ + /* ================================================================ */ + + describe("agent presence", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + async function setup(caps?: AgentCapability[]) { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# Title\n\nBody." }), + })); + const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); + return { agent, token: (m as { token: string }).token }; + } + + /** Finds the (at most one) agent presence state among a client's awareness states. */ + function findAgentState(awareness: awarenessProtocol.Awareness) { + return Array.from(awareness.getStates().values()).find( + (s) => (s as { user?: { isAgent?: boolean } }).user?.isAgent, + ) as { user: { name: string; isAgent: boolean }; status?: string; cursor?: { anchor: unknown; head: unknown } } | undefined; + } + + it("broadcasts a presence state every connected client can decode", async () => { + const { agent, token } = await setup(); + const a = connectYjsClient(agent); + const b = connectYjsClient(agent); + + const result = await agent.agentJoin(token, "typing"); + expect(result).toEqual({ ok: true }); + + for (const client of [a, b]) { + expect(findAgentState(client.awareness)).toMatchObject({ + user: { name: "scribe", isAgent: true }, + status: "typing", + }); + } + cleanup(a, b); + }); + + it("replays current agent presence to a client that connects after join", async () => { + const { agent, token } = await setup(); + await agent.agentJoin(token); + + const late = connectYjsClient(agent); + expect(findAgentState(late.awareness)).toMatchObject({ + user: { name: "scribe", isAgent: true }, + }); + cleanup(late); + }); + + it("replays nothing for an agent that never joined", async () => { + const { agent } = await setup(); + const late = connectYjsClient(agent); + expect(findAgentState(late.awareness)).toBeUndefined(); + cleanup(late); + }); + + it("removes presence for all connections immediately on leave", async () => { + const { agent, token } = await setup(); + const a = connectYjsClient(agent); + await agent.agentJoin(token); + expect(findAgentState(a.awareness)).toBeDefined(); + + const result = await agent.agentLeave(token); + expect(result).toEqual({ ok: true }); + expect(findAgentState(a.awareness)).toBeUndefined(); + cleanup(a); + }); + + it("rejects join/leave for an invalid token", async () => { + const { agent } = await setup(); + expect(await agent.agentJoin("vpr_nonexistent")).toMatchObject({ + error: { code: "invalid_token" }, + }); + expect(await agent.agentLeave("vpr_nonexistent")).toMatchObject({ + error: { code: "invalid_token" }, + }); + }); + + it("removes presence automatically after 5 minutes of inactivity", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(); + const a = connectYjsClient(agent); + await agent.agentJoin(token); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 - 1); + expect(findAgentState(a.awareness)).toBeDefined(); + + await vi.advanceTimersByTimeAsync(2); + expect(findAgentState(a.awareness)).toBeUndefined(); + cleanup(a); + }); + + it("resets the idle timer on every performance, keeping a busy agent present", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + const a = connectYjsClient(agent); + await agent.agentJoin(token); + + // Just under the idle window, perform a (quick) mutation — its + // typing ticks call onPerformanceCursor, which resets the timer. Only + // advance far enough to finish typing "hi" (well under 5 minutes) — + // vi.runAllTimersAsync() would also drain the *freshly reset* 5-minute + // idle timeout in the same call, defeating the point of the test. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + await agent.agentInsert(token, { where: "append", markdown: "hi", pace: "natural" }); + await vi.advanceTimersByTimeAsync(200); + + // Another 4 minutes — past the original 5-minute mark from join, but + // well within 5 minutes of the reset above. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + expect(findAgentState(a.awareness)).toBeDefined(); + cleanup(a); + }); + + it("populates a y-tiptap-shaped cursor field during a performance, even for an agent that never joined", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { agent, token } = await setup(["write"]); + const a = connectYjsClient(agent); + + // Bounded advance, not vi.runAllTimersAsync(): each tick's + // onPerformanceCursor resets a fresh 5-minute idle timeout, which + // runAllTimersAsync() would drain too, removing presence again before + // this assertion runs. + await agent.agentInsert(token, { where: "append", markdown: "abcdefghij", pace: "natural" }); + await vi.advanceTimersByTimeAsync(800); + + const state = findAgentState(a.awareness); + expect(state).toMatchObject({ user: { name: "scribe", isAgent: true } }); + expect(state?.cursor).toBeDefined(); + // Both fields must be decodable Y.RelativePosition JSON (the shape + // @tiptap/y-tiptap's cursor plugin expects — see agent-awareness.ts). + expect(() => Y.createRelativePositionFromJSON(state!.cursor!.anchor as never)).not.toThrow(); + expect(() => Y.createRelativePositionFromJSON(state!.cursor!.head as never)).not.toThrow(); + cleanup(a); + }); + + it("clears agent presence and idle timers on alarm", async () => { + const { agent, token } = await setup(); + const a = connectYjsClient(agent); + await agent.agentJoin(token); + expect(findAgentState(a.awareness)).toBeDefined(); + + await agent.alarm(); + + mockConnectionMap.clear(); + const b = connectYjsClient(agent); + expect(findAgentState(b.awareness)).toBeUndefined(); + cleanup(a, b); + }); + }); }); diff --git a/tests/unit/lib/agent-awareness.test.ts b/tests/unit/lib/agent-awareness.test.ts new file mode 100644 index 00000000..3f09b357 --- /dev/null +++ b/tests/unit/lib/agent-awareness.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import * as awarenessProtocol from "y-protocols/awareness"; +import * as decoding from "lib0/decoding"; +import { encodeAgentAwareness, agentClientId } from "~/lib/agent-awareness"; + +describe("encodeAgentAwareness", () => { + it("encodes a state the protocol can apply", () => { + const frame = encodeAgentAwareness(12345, 1, { + user: { name: "scribe", color: "#4DD0E1", isAgent: true }, + }); + const dec = decoding.createDecoder(frame); + expect(decoding.readVarUint(dec)).toBe(1); // MSG_AWARENESS + const aw = new awarenessProtocol.Awareness(new Y.Doc()); + awarenessProtocol.applyAwarenessUpdate(aw, decoding.readVarUint8Array(dec), "test"); + expect(aw.getStates().get(12345)).toMatchObject({ + user: { name: "scribe", isAgent: true }, + }); + }); + + it("round-trips a null state as presence removal", () => { + const doc = new Y.Doc(); + const aw = new awarenessProtocol.Awareness(doc); + + // First establish a present state at clock 1. + const present = encodeAgentAwareness(999, 1, { + user: { name: "scribe", color: "#4DD0E1", isAgent: true }, + }); + let dec = decoding.createDecoder(present); + decoding.readVarUint(dec); + awarenessProtocol.applyAwarenessUpdate(aw, decoding.readVarUint8Array(dec), "test"); + expect(aw.getStates().has(999)).toBe(true); + + // A higher-clock null frame removes it. + const removed = encodeAgentAwareness(999, 2, null); + dec = decoding.createDecoder(removed); + decoding.readVarUint(dec); + awarenessProtocol.applyAwarenessUpdate(aw, decoding.readVarUint8Array(dec), "test"); + expect(aw.getStates().has(999)).toBe(false); + }); + + it("round-trips a cursor field shaped for y-tiptap's cursor plugin", () => { + const doc = new Y.Doc(); + const ytext = doc.getText("t"); + ytext.insert(0, "hello"); + const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2); + const posJson = Y.relativePositionToJSON(relPos); + + const aw = new awarenessProtocol.Awareness(new Y.Doc()); + const frame = encodeAgentAwareness(42, 1, { + user: { name: "scribe", color: "#4DD0E1", isAgent: true }, + cursor: { anchor: posJson, head: posJson }, + }); + const dec = decoding.createDecoder(frame); + decoding.readVarUint(dec); + awarenessProtocol.applyAwarenessUpdate(aw, decoding.readVarUint8Array(dec), "test"); + + const state = aw.getStates().get(42) as { cursor: { anchor: unknown; head: unknown } }; + expect(() => Y.createRelativePositionFromJSON(state.cursor.anchor as never)).not.toThrow(); + }); +}); + +describe("agentClientId", () => { + it("is stable for the same name", () => { + expect(agentClientId("scribe")).toBe(agentClientId("scribe")); + }); + + it("differs across distinct names (no trivial collision)", () => { + expect(agentClientId("scribe")).not.toBe(agentClientId("editor-bot")); + }); + + it("is always a non-zero positive integer", () => { + for (const name of ["a", "scribe", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "0"]) { + const id = agentClientId(name); + expect(id).toBeGreaterThan(0); + expect(Number.isInteger(id)).toBe(true); + } + }); +}); From 743c87beff63a7857fcc6426661a85b5ec478b3e Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 10:49:55 -0700 Subject: [PATCH 17/83] Add document events with mention detection and long-poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `events` table (seq/type/payload/created_at) plus `agentAwaitEvents` long-polling for it. Human-origin Yjs transactions are scanned via frag.observeDeep for @mentions of roster agents (with a 30s-capped doc_changed digest), and a threads-map observer notifies an agent when a human replies to its thread. Agent-originated mutations are tagged with a Yjs "agent" transaction origin so they're excluded from both detectors — this also covers onRequest's initial content/threads import, which isn't a live human edit. agentComment/agentReply were missing from the RPC surface (deferred from Task 5) and are needed to produce thread_reply events, so they're added here too: agentComment creates a ThreadData entry (requires `comment`), agentReply appends to one (doc_not_found for an unknown thread id). Co-Authored-By: Claude Fable 5 --- agents/document.ts | 338 ++++++++++++++++-- .../integration/agents/document-agent.test.ts | 209 ++++++++++- 2 files changed, 504 insertions(+), 43 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index 8f988e78..39e61b8d 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -11,6 +11,7 @@ import { AGENT_NAME_RE, DEFAULT_CAPABILITIES, formatAnchor, + findMentions, RATE_LIMIT_MUTATIONS_PER_MIN, RATE_LIMIT_CHARS_PER_HOUR, } from "../app/shared/agent-protocol"; @@ -19,7 +20,17 @@ import { getBlocks, yDocToMarkdown, resolveAnchor, insertMarkdownBlocks, deleteB import { parseCriticMarkupToContent } from "../app/lib/critic-parser"; import { chunkTyping } from "../app/lib/performance-chunks"; import { encodeAgentAwareness, agentClientId, type AgentPresenceState } from "../app/lib/agent-awareness"; -import type { ThreadData } from "../app/shared/types"; +import type { ThreadData, ThreadReply } from "../app/shared/types"; + +/** A recorded document event's public shape, as returned by agentAwaitEvents. */ +type DocEventType = "mention" | "thread_reply" | "doc_changed"; + +interface EventRow { + seq: number; + type: string; + payload: string; + created_at: number; +} /** How long an agent can go without a join/performance before its presence is auto-removed. */ const AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; @@ -123,6 +134,11 @@ class DocumentAgent extends Agent { /** Per-agent 5-minute idle timer, reset on every join/performance-cursor update. */ private agentIdleTimers = new Map>(); + /** Resolvers parked by agentAwaitEvents long-polls with nothing to return yet; flushed by recordEvent. */ + private eventWaiters: (() => void)[] = []; + /** Timestamp of the last "doc_changed" digest event, to cap it at one per 30s. */ + private lastDigestAt = 0; + private ensureInitialised(): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { if (this.doc && this.awareness) { return { doc: this.doc, awareness: this.awareness }; @@ -159,6 +175,14 @@ class DocumentAgent extends Agent { created_at INTEGER ) `; + this.sql` + CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT, + payload TEXT, + created_at INTEGER + ) + `; // Load persisted state const rows = this.sql<{ value: ArrayBuffer }>` @@ -194,6 +218,68 @@ class DocumentAgent extends Agent { this.sql`DELETE FROM performances WHERE id = ${row.id}`; } + // Mention detection + doc_changed digests: only for human-originated + // changes to document content. Agent RPCs tag their own transactions + // with the "agent" origin (see applyMutation/performTypedInsert/ + // performTypedSuggest) specifically so this observer can ignore them — + // an agent shouldn't get a "mention" notification for text it just + // typed itself, nor should its own edits consume the doc_changed + // digest window meant for humans. + const frag = this.doc.getXmlFragment("default"); + frag.observeDeep((events, transaction) => { + if (transaction.origin === "agent") return; + + const now = Date.now(); + if (now - this.lastDigestAt >= 30_000) { + this.lastDigestAt = now; + this.recordEvent("doc_changed", {}); + } + + const rosterNames = this.getRosterNamesSync(); + if (rosterNames.length === 0) return; + + for (const event of events) { + if (!(event.target instanceof Y.XmlText)) continue; + for (const op of event.changes.delta) { + if (typeof op.insert !== "string") continue; + const mentions = findMentions(op.insert, rosterNames); + for (const name of mentions) { + const text = this.findBlockTextForXmlText(event.target) ?? op.insert; + this.recordEvent("mention", { agent: name, text }); + } + } + } + }); + + // Human replies to an agent-authored thread: notify that agent. + // Deliberately simple (per the design brief) — it re-checks "is the + // last reply not by the thread's own agent author" on every + // human-origin change to the thread, so it can re-fire on later + // unrelated edits to the same thread (e.g. a resolve toggle) rather + // than tracking precisely which reply is new. + const threadsMap = this.doc.getMap("threads"); + threadsMap.observe((event, transaction) => { + if (transaction.origin === "agent") return; + + const rosterNames = this.getRosterNamesSync(); + if (rosterNames.length === 0) return; + + for (const key of event.keysChanged) { + const raw = threadsMap.get(key); + if (!raw) continue; + let thread: ThreadData; + try { + thread = JSON.parse(raw) as ThreadData; + } catch { + continue; + } + if (!rosterNames.includes(thread.author?.name)) continue; + const lastReply = thread.replies[thread.replies.length - 1]; + if (!lastReply || lastReply.author?.name === thread.author.name) continue; + this.recordEvent("thread_reply", { agent: thread.author.name, threadId: thread.id }); + } + }); + return { doc: this.doc, awareness: this.awareness }; } @@ -305,6 +391,11 @@ class DocumentAgent extends Agent { this.sql`DELETE FROM performances`; this.performanceQueue = []; this.isPerforming = false; + // Recorded events (mentions, thread replies, doc_changed digests) are + // meaningless once the document they refer to is gone. + this.sql`DELETE FROM events`; + for (const finish of this.eventWaiters) finish(); + this.eventWaiters = []; // Agent presence belongs to a document that no longer exists — drop it // and cancel every pending idle timer along with it. for (const timer of this.agentIdleTimers.values()) { @@ -350,40 +441,44 @@ class DocumentAgent extends Agent { if (contentType.includes("application/json")) { try { const body = await request.json() as { content?: string; threads?: unknown[]; onboarding?: boolean }; - if (body.content) { - // Parse CriticMarkup and apply as marks on XmlText - const { parseCriticMarkupToContent } = await import("../app/lib/critic-parser"); - const frag = doc.getXmlFragment("default"); - if (frag.length === 0) { - const lines = body.content.split("\n"); - for (const line of lines) { - const { cleanText, marks } = parseCriticMarkupToContent(line); - const para = new Y.XmlElement("paragraph"); - const ytext = new Y.XmlText(cleanText); - // Apply marks via Yjs formatting attributes - for (const mark of marks) { - const attrs: Record> = {}; - attrs[mark.type] = mark.attrs ?? {}; - ytext.format(mark.from, mark.to - mark.from, attrs); + // Tagged "agent" (system import, not a live edit) so it doesn't + // register as a human edit for mention/doc_changed/thread_reply + // detection — see the frag/threads observers in ensureInitialised. + doc.transact(() => { + if (body.content) { + // Parse CriticMarkup and apply as marks on XmlText + const frag = doc.getXmlFragment("default"); + if (frag.length === 0) { + const lines = body.content.split("\n"); + for (const line of lines) { + const { cleanText, marks } = parseCriticMarkupToContent(line); + const para = new Y.XmlElement("paragraph"); + const ytext = new Y.XmlText(cleanText); + // Apply marks via Yjs formatting attributes + for (const mark of marks) { + const attrs: Record> = {}; + attrs[mark.type] = mark.attrs ?? {}; + ytext.format(mark.from, mark.to - mark.from, attrs); + } + para.insert(0, [ytext]); + frag.insert(frag.length, [para]); } - para.insert(0, [ytext]); - frag.insert(frag.length, [para]); } } - } - if (body.threads && Array.isArray(body.threads)) { - const threadsMap = doc.getMap("threads"); - for (const thread of body.threads) { - const t = thread as { id?: string }; - if (t.id) { - threadsMap.set(t.id, JSON.stringify(thread)); + if (body.threads && Array.isArray(body.threads)) { + const threadsMap = doc.getMap("threads"); + for (const thread of body.threads) { + const t = thread as { id?: string }; + if (t.id) { + threadsMap.set(t.id, JSON.stringify(thread)); + } } } - } - if (body.onboarding) { - const docState = doc.getMap("docState"); - docState.set("onboarding", "true"); - } + if (body.onboarding) { + const docState = doc.getMap("docState"); + docState.set("onboarding", "true"); + } + }, "agent"); } catch (err) { // If it's an unsupported CriticMarkup error, return it if (err instanceof Error && err.message.includes("Unsupported CriticMarkup")) { @@ -497,6 +592,96 @@ class DocumentAgent extends Agent { return rows.map(rowToRosterEntry); } + /** + * Synchronous roster-name lookup for use inside Yjs observer callbacks + * (which cannot await getAgentRoster's async signature, even though its + * body is itself fully synchronous SQL access). + */ + private getRosterNamesSync(): string[] { + const rows = this.sql<{ name: string }>`SELECT name FROM agent_tokens`; + return rows.map((r) => r.name); + } + + /** + * Finds the markdown text (via getBlocks) of the block containing a given + * Y.XmlText node, for attaching context to a mention event. Returns null + * if the node isn't a direct child of a top-level block element (e.g. it + * was already removed from the fragment by a later concurrent edit). + */ + private findBlockTextForXmlText(ytext: Y.XmlText): string | null { + if (!this.doc) return null; + const parent = ytext.parent; + if (!(parent instanceof Y.XmlElement)) return null; + const frag = this.doc.getXmlFragment("default"); + const index = frag.toArray().indexOf(parent); + if (index === -1) return null; + return getBlocks(this.doc)[index]?.text ?? null; + } + + /** + * Inserts a row into `events` and wakes every agentAwaitEvents long-poll + * currently parked with nothing to return — each re-queries past its own + * cursor once woken, so no event data needs to travel through the + * resolver itself. + */ + private recordEvent(type: string, payload: unknown): void { + this.ensureInitialised(); + this.sql` + INSERT INTO events (type, payload, created_at) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()}) + `; + const waiters = this.eventWaiters; + this.eventWaiters = []; + for (const resolve of waiters) resolve(); + } + + /** + * Long-polls for events past `cursor` (default 0, i.e. everything). + * Returns immediately if any exist; otherwise parks until either + * recordEvent flushes it or `timeoutMs` (capped at 50s) elapses, then + * returns whatever is available at that point (possibly still empty). + * Only a valid token is required — read is implied. + */ + async agentAwaitEvents( + token: string, + args: { cursor?: number; timeoutMs?: number }, + ): Promise< + | { events: { seq: number; type: DocEventType; payload: unknown }[]; cursor: number } + | { error: AgentError } + > { + const verified = await this.verifyAgentToken(token); + if ("error" in verified) return verified; + + const cursor = args.cursor ?? 0; + + const readPast = (): { seq: number; type: DocEventType; payload: unknown }[] => { + const rows = this.sql` + SELECT * FROM events WHERE seq > ${cursor} ORDER BY seq ASC + `; + return rows.map((r) => ({ + seq: r.seq, + type: r.type as DocEventType, + payload: JSON.parse(r.payload) as unknown, + })); + }; + + let events = readPast(); + if (events.length === 0) { + const timeoutMs = Math.min(args.timeoutMs ?? 50_000, 50_000); + await new Promise((resolve) => { + const finish = () => { + this.eventWaiters = this.eventWaiters.filter((w) => w !== finish); + clearTimeout(timer); + resolve(); + }; + this.eventWaiters.push(finish); + const timer = setTimeout(finish, timeoutMs); + }); + events = readPast(); + } + + return { events, cursor: events.length > 0 ? events[events.length - 1].seq : cursor }; + } + /** Revokes an agent's token by name. Idempotent. */ async revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }> { this.ensureInitialised(); @@ -695,6 +880,81 @@ class DocumentAgent extends Agent { }); } + /** + * Creates a new comment thread anchored at a block. `anchor` is validated + * (stale_anchor on failure) but — like ThreadData itself — not stored on + * the thread; `quote` maps to `highlightText`, `text` to `commentText`, + * matching how the client's own comment threads are shaped + * (app/lib/comment-threads.ts / useThreads.ts). Requires `comment`. + */ + async agentComment( + token: string, + args: { anchor: string; quote?: string; text: string }, + ): Promise<{ threadId: string } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token, "comment"); + if ("error" in verified) return verified; + + const { doc } = this.ensureInitialised(); + const resolved = resolveAnchor(doc, args.anchor); + if ("error" in resolved) { + return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; + } + + const { name, color } = verified.entry; + const id = crypto.randomUUID(); + const thread: ThreadData = { + id, + commentText: args.text, + highlightText: args.quote, + author: { name, color, colorLight: color }, + createdAt: Date.now(), + resolved: false, + replies: [], + }; + + doc.transact(() => { + doc.getMap("threads").set(id, JSON.stringify(thread)); + }, "agent"); + + return { threadId: id }; + } + + /** + * Appends a reply to an existing thread. Requires `comment`. A missing + * thread returns `doc_not_found` (the closed AgentErrorCode union has no + * dedicated "thread not found" code). + */ + async agentReply( + token: string, + args: { threadId: string; text: string }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyAgentToken(token, "comment"); + if ("error" in verified) return verified; + + const { doc } = this.ensureInitialised(); + const threadsMap = doc.getMap("threads"); + const raw = threadsMap.get(args.threadId); + if (!raw) { + return { error: { code: "doc_not_found", message: "thread not found" } }; + } + + const thread = JSON.parse(raw) as ThreadData; + const { name, color } = verified.entry; + const reply: ThreadReply = { + id: crypto.randomUUID(), + author: { name, color, colorLight: color }, + text: args.text, + createdAt: Date.now(), + }; + thread.replies.push(reply); + + doc.transact(() => { + threadsMap.set(args.threadId, JSON.stringify(thread)); + }, "agent"); + + return { ok: true }; + } + /** * Marks an agent present in awareness (visible in the presence stack and, * once it performs a mutation, as a caret) and (re)starts its 5-minute @@ -920,7 +1180,7 @@ class DocumentAgent extends Agent { } if (mutation.markdown.includes("\n")) { - doc.transact(() => insertMarkdownBlocks(doc, index, mutation.markdown)); + doc.transact(() => insertMarkdownBlocks(doc, index, mutation.markdown), "agent"); this.deletePerformanceRow(item.id); return; } @@ -932,7 +1192,7 @@ class DocumentAgent extends Agent { const el = new Y.XmlElement("paragraph"); const ytext = new Y.XmlText(""); el.insert(0, [ytext]); - doc.transact(() => doc.getXmlFragment("default").insert(index, [el])); + doc.transact(() => doc.getXmlFragment("default").insert(index, [el]), "agent"); this.deletePerformanceRow(item.id); let typed = 0; @@ -945,7 +1205,7 @@ class DocumentAgent extends Agent { // The paragraph (or its text) is gone — nothing sane left to type into. return; } - doc.transact(() => ytext.insert(absPos.index, tick.chunk)); + doc.transact(() => ytext.insert(absPos.index, tick.chunk), "agent"); typed += tick.chunk.length; const caretOffset = absPos.index + tick.chunk.length; relPos = Y.createRelativePositionFromTypeIndex(ytext, caretOffset); @@ -960,7 +1220,7 @@ class DocumentAgent extends Agent { for (const mark of marks) { ytext.format(mark.from, mark.to - mark.from, { [mark.type]: mark.attrs ?? {} }); } - }); + }, "agent"); } } @@ -1006,7 +1266,7 @@ class DocumentAgent extends Agent { } // Claim the slot now, synchronously — see the doc comment above. - doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} })); + doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} }), "agent"); let relPos = Y.createRelativePositionFromTypeIndex(ytext, pos + mutation.find.length); this.deletePerformanceRow(item.id); @@ -1019,7 +1279,7 @@ class DocumentAgent extends Agent { // The block (or its text) is gone — nothing sane left to type into. return; } - doc.transact(() => ytext.insert(absPos.index, tick.chunk, { criticAddition: {} })); + doc.transact(() => ytext.insert(absPos.index, tick.chunk, { criticAddition: {} }), "agent"); const caretOffset = absPos.index + tick.chunk.length; relPos = Y.createRelativePositionFromTypeIndex(ytext, caretOffset); this.onPerformanceCursor(item.agentName, ytext, caretOffset); @@ -1095,7 +1355,7 @@ class DocumentAgent extends Agent { doc.transact(() => { insertMarkdownBlocks(doc, index, m.markdown); - }); + }, "agent"); return { ok: true }; } @@ -1130,7 +1390,7 @@ class DocumentAgent extends Agent { doc.transact(() => { deleteBlocks(doc, fromIndex, toIndex); insertMarkdownBlocks(doc, fromIndex, m.markdown); - }); + }, "agent"); return { ok: true }; } @@ -1163,7 +1423,7 @@ class DocumentAgent extends Agent { doc.transact(() => { ytext.format(pos, m.find.length, { criticDeletion: {} }); ytext.insert(pos + m.find.length, m.replacement, { criticAddition: {} }); - }); + }, "agent"); return { ok: true }; } diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index b5136728..f03af491 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -109,6 +109,15 @@ vi.mock("agents", () => ({ cols.forEach((col, i) => { row[col] = values[i]; }); + // `events.seq` is an AUTOINCREMENT primary key the real schema + // assigns; the DO code never supplies it explicitly (see + // recordEvent), so synthesize a monotonically increasing one + // here, matching real SQLite's behavior closely enough for + // "insert then query by seq" tests. + if (table === "events" && !("seq" in row)) { + const maxSeq = rows.reduce((m, r) => Math.max(m, (r.seq as number) ?? 0), 0); + row.seq = maxSeq + 1; + } rows.push(row); } return []; @@ -141,10 +150,16 @@ vi.mock("agents", () => ({ } if (query.startsWith("select")) { - const whereMatch = /where\s+(\w+)\s*=/i.exec(raw); - let result = whereMatch - ? rows.filter((row) => row[whereMatch[1]] === values[0]) - : rows; + // Supports plain equality (`col = ?`) and, for the events cursor + // query, a strictly-greater comparison (`seq > ?`). + const whereMatch = /where\s+(\w+)\s*(=|>)\s*/i.exec(raw); + let result = rows; + if (whereMatch) { + const [, col, op] = whereMatch; + result = rows.filter((row) => + op === ">" ? (row[col] as number) > (values[0] as number) : row[col] === values[0], + ); + } const orderMatch = /order by\s+(\w+)/i.exec(raw); if (orderMatch) { @@ -329,6 +344,18 @@ describe("DocumentAgent", () => { } } + /** + * Flushes real (un-faked) pending async work — e.g. the crypto.subtle + * hashing inside verifyAgentToken — via a real setImmediate, independent + * of vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }). Needed + * before vi.advanceTimersByTimeAsync() when a call under test does real + * async work *before* registering the fake timer being awaited — + * otherwise the advance can race ahead of the timer's registration. + */ + function flushMicrotasks(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } + /* ================================================================ */ /* HTTP GET */ /* ================================================================ */ @@ -1239,4 +1266,178 @@ describe("DocumentAgent", () => { cleanup(a, b); }); }); + + /* ================================================================ */ + /* Events: mentions, thread replies, await_events long-poll */ + /* ================================================================ */ + + describe("agent events", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + async function setup(caps?: AgentCapability[]) { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Hello there." }), + })); + const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); + return { agent, token: (m as { token: string }).token }; + } + + it("records a mention through the real Yjs sync path when a human edits an existing block", async () => { + const { agent, token } = await setup(); + const client = connectYjsClient(agent); + + // A human types more text into the already-synced first paragraph — + // this is a real edit to an *existing* Y.XmlText, applied through the + // agent's onMessage/syncProtocol path with a null (human) origin. + const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; + const ytext = para.get(0) as Y.XmlText; + ytext.insert(ytext.length, " ping @scribe please"); + + const result = await agent.agentAwaitEvents(token, {}); + expect("events" in result).toBe(true); + const events = "events" in result ? result.events : []; + const mention = events.find((e) => e.type === "mention"); + expect(mention).toBeDefined(); + expect(mention).toMatchObject({ + type: "mention", + payload: { agent: "scribe", text: expect.stringContaining("@scribe") }, + }); + expect(typeof mention?.seq).toBe("number"); + expect("cursor" in result && result.cursor).toBe(events[events.length - 1].seq); + cleanup(client); + }); + + it("resolves empty after the timeout when no events occur (fake timers)", async () => { + const { agent, token } = await setup(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const promise = agent.agentAwaitEvents(token, { timeoutMs: 50 }); + // agentAwaitEvents awaits a real (un-faked) crypto.subtle.digest call + // inside verifyAgentToken before it ever registers its long-poll + // setTimeout — flush that real async work first, or advanceTimersByTimeAsync + // races ahead of the timer even existing yet. + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(50); + const result = await promise; + + expect(result).toEqual({ events: [], cursor: 0 }); + }); + + it("excludes already-seen events once the cursor advances past them", async () => { + const { agent, token } = await setup(); + const client = connectYjsClient(agent); + + const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; + const ytext = para.get(0) as Y.XmlText; + ytext.insert(ytext.length, " ping @scribe please"); + + const first = await agent.agentAwaitEvents(token, {}); + const cursor = "cursor" in first ? first.cursor : -1; + expect(cursor).toBeGreaterThan(0); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const secondPromise = agent.agentAwaitEvents(token, { cursor, timeoutMs: 50 }); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(50); + const second = await secondPromise; + + expect(second).toEqual({ events: [], cursor }); + cleanup(client); + }); + + it("round-trips a comment and reply, and records a thread_reply event for a human reply", async () => { + const { agent, token } = await setup(["comment"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + + const created = await agent.agentComment(token, { anchor, quote: "Hello", text: "needs work" }); + expect("threadId" in created).toBe(true); + const threadId = "threadId" in created ? created.threadId : ""; + + const afterCreate = await agent.agentRead(token); + const threads = "threads" in afterCreate ? afterCreate.threads : []; + expect(threads).toHaveLength(1); + expect(threads[0]).toMatchObject({ + id: threadId, + commentText: "needs work", + highlightText: "Hello", + author: { name: "scribe" }, + resolved: false, + replies: [], + }); + + // A human replies directly on the shared Y.Map, the same way + // useThreads.addReply does client-side (an untagged — human-origin — + // transaction). + const client = connectYjsClient(agent); + const threadsMap = client.doc.getMap("threads"); + const raw = threadsMap.get(threadId)!; + const thread = JSON.parse(raw); + thread.replies.push({ + id: "r1", + author: { name: "Nick", color: "#000", colorLight: "#000" }, + text: "thanks!", + createdAt: Date.now(), + }); + threadsMap.set(threadId, JSON.stringify(thread)); + + const result = await agent.agentAwaitEvents(token, {}); + const events = "events" in result ? result.events : []; + const threadReply = events.find((e) => e.type === "thread_reply"); + expect(threadReply).toMatchObject({ + type: "thread_reply", + payload: { agent: "scribe", threadId }, + }); + + cleanup(client); + }); + + it("agentComment requires the comment capability", async () => { + const { agent, token } = await setup([]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + const result = await agent.agentComment(token, { anchor, text: "hi" }); + expect(result).toMatchObject({ error: { code: "capability_denied" } }); + }); + + it("agentReply appends a reply, attributed to the replying agent", async () => { + const { agent, token } = await setup(["comment"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + const created = await agent.agentComment(token, { anchor, text: "hi" }); + const threadId = "threadId" in created ? created.threadId : ""; + + const result = await agent.agentReply(token, { threadId, text: "reply text" }); + expect(result).toEqual({ ok: true }); + + const after = await agent.agentRead(token); + const threads = "threads" in after ? after.threads : []; + expect(threads[0].replies).toHaveLength(1); + expect(threads[0].replies[0]).toMatchObject({ text: "reply text", author: { name: "scribe" } }); + }); + + it("agentReply returns doc_not_found for an unknown thread", async () => { + const { agent, token } = await setup(["comment"]); + const result = await agent.agentReply(token, { threadId: "nope", text: "x" }); + expect(result).toMatchObject({ error: { code: "doc_not_found" } }); + }); + + it("prunes events on doc expiry (alarm)", async () => { + const { agent, token } = await setup(); + const client = connectYjsClient(agent); + const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; + const ytext = para.get(0) as Y.XmlText; + ytext.insert(ytext.length, " ping @scribe please"); + await agent.agentAwaitEvents(token, {}); + cleanup(client); + + await agent.alarm(); + + expect(mockTables.get("events") ?? []).toEqual([]); + }); + }); }); From a7b82b608483eda8b09fbaf75f43ee63c2050ce3 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 11:01:25 -0700 Subject: [PATCH 18/83] Fix thread_reply over-firing, missing rate limits, and error code overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - thread_reply now fires only when a human-origin threads-map edit actually grows replies.length (via event.changes.keys' oldValue), not on any edit to an agent-authored thread (resolve toggles, re-saves). - agentComment/agentReply now call checkRateLimit like the other mutation RPCs, instead of bypassing the agent's rate-limit budget. - Added "thread_not_found" to the closed AgentErrorCode union and use it from agentReply instead of overloading "doc_not_found". Also replaces the fake-timer tests' single-flush workaround (flushMicrotasks) with waitForTimerRegistered, which polls vi.getTimerCount() instead of guessing a fixed number of setImmediate ticks — the single flush was an intermittent hang under full-suite load (verifyAgentToken's real crypto.subtle.digest call didn't always resolve in one tick before vi.advanceTimersByTimeAsync raced ahead of the long-poll's setTimeout ever being registered). Co-Authored-By: Claude Fable 5 --- agents/document.ts | 34 +++++-- app/shared/agent-protocol.ts | 3 +- .../integration/agents/document-agent.test.ts | 97 ++++++++++++++++--- 3 files changed, 108 insertions(+), 26 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index 39e61b8d..d15a71be 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -251,12 +251,11 @@ class DocumentAgent extends Agent { } }); - // Human replies to an agent-authored thread: notify that agent. - // Deliberately simple (per the design brief) — it re-checks "is the - // last reply not by the thread's own agent author" on every - // human-origin change to the thread, so it can re-fire on later - // unrelated edits to the same thread (e.g. a resolve toggle) rather - // than tracking precisely which reply is new. + // Human replies to an agent-authored thread: notify that agent. Only + // fires when a reply was actually *added* — compares the previous + // replies.length (from event.changes.keys' oldValue, the prior raw + // JSON) against the new one, so a resolve toggle or any other edit to + // an already-replied-to thread doesn't re-fire the notification. const threadsMap = this.doc.getMap("threads"); threadsMap.observe((event, transaction) => { if (transaction.origin === "agent") return; @@ -274,6 +273,18 @@ class DocumentAgent extends Agent { continue; } if (!rosterNames.includes(thread.author?.name)) continue; + + const change = event.changes.keys.get(key); + if (!change || change.action !== "update") continue; // "add" = brand-new thread, not a reply + let previousReplyCount = 0; + try { + const previous = JSON.parse(change.oldValue) as ThreadData; + previousReplyCount = previous.replies.length; + } catch { + continue; + } + if (thread.replies.length <= previousReplyCount) continue; + const lastReply = thread.replies[thread.replies.length - 1]; if (!lastReply || lastReply.author?.name === thread.author.name) continue; this.recordEvent("thread_reply", { agent: thread.author.name, threadId: thread.id }); @@ -894,6 +905,9 @@ class DocumentAgent extends Agent { const verified = await this.verifyAgentToken(token, "comment"); if ("error" in verified) return verified; + const rateLimited = await this.checkRateLimit(token, args.text.length); + if (rateLimited) return rateLimited; + const { doc } = this.ensureInitialised(); const resolved = resolveAnchor(doc, args.anchor); if ("error" in resolved) { @@ -921,8 +935,7 @@ class DocumentAgent extends Agent { /** * Appends a reply to an existing thread. Requires `comment`. A missing - * thread returns `doc_not_found` (the closed AgentErrorCode union has no - * dedicated "thread not found" code). + * thread returns `thread_not_found`. */ async agentReply( token: string, @@ -931,11 +944,14 @@ class DocumentAgent extends Agent { const verified = await this.verifyAgentToken(token, "comment"); if ("error" in verified) return verified; + const rateLimited = await this.checkRateLimit(token, args.text.length); + if (rateLimited) return rateLimited; + const { doc } = this.ensureInitialised(); const threadsMap = doc.getMap("threads"); const raw = threadsMap.get(args.threadId); if (!raw) { - return { error: { code: "doc_not_found", message: "thread not found" } }; + return { error: { code: "thread_not_found", message: "thread not found" } }; } const thread = JSON.parse(raw) as ThreadData; diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index a5cb7a1a..5fc6af2b 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -33,7 +33,8 @@ export type AgentErrorCode = | "doc_expired" | "find_not_matched" | "rate_limited" - | "invalid_name"; + | "invalid_name" + | "thread_not_found"; export const AGENT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; export const RESERVED_SLUGS = [ diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index f03af491..36a0fa5c 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -345,15 +345,22 @@ describe("DocumentAgent", () => { } /** - * Flushes real (un-faked) pending async work — e.g. the crypto.subtle - * hashing inside verifyAgentToken — via a real setImmediate, independent - * of vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }). Needed - * before vi.advanceTimersByTimeAsync() when a call under test does real - * async work *before* registering the fake timer being awaited — - * otherwise the advance can race ahead of the timer's registration. + * Waits for a call under test to register its (faked) setTimeout before + * vi.advanceTimersByTimeAsync() runs. agentAwaitEvents does real, + * un-faked async work (crypto.subtle.digest inside verifyAgentToken) + * *before* parking on a setTimeout — advancing fake time too early would + * race ahead of that registration and hang forever, since no further + * real time ever passes to let the crypto step catch up. Polls + * vi.getTimerCount() via real (un-faked) setImmediate ticks rather than + * a fixed number of flushes, so it's robust regardless of how many real + * event-loop turns the crypto call actually needs (which varies under + * system load) — capped so a genuine bug still fails fast instead of + * hanging. */ - function flushMicrotasks(): Promise { - return new Promise((resolve) => setImmediate(resolve)); + async function waitForTimerRegistered(): Promise { + for (let i = 0; i < 200 && vi.getTimerCount() === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } } /* ================================================================ */ @@ -1316,11 +1323,7 @@ describe("DocumentAgent", () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const promise = agent.agentAwaitEvents(token, { timeoutMs: 50 }); - // agentAwaitEvents awaits a real (un-faked) crypto.subtle.digest call - // inside verifyAgentToken before it ever registers its long-poll - // setTimeout — flush that real async work first, or advanceTimersByTimeAsync - // races ahead of the timer even existing yet. - await flushMicrotasks(); + await waitForTimerRegistered(); await vi.advanceTimersByTimeAsync(50); const result = await promise; @@ -1341,7 +1344,7 @@ describe("DocumentAgent", () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const secondPromise = agent.agentAwaitEvents(token, { cursor, timeoutMs: 50 }); - await flushMicrotasks(); + await waitForTimerRegistered(); await vi.advanceTimersByTimeAsync(50); const second = await secondPromise; @@ -1420,10 +1423,72 @@ describe("DocumentAgent", () => { expect(threads[0].replies[0]).toMatchObject({ text: "reply text", author: { name: "scribe" } }); }); - it("agentReply returns doc_not_found for an unknown thread", async () => { + it("agentReply returns thread_not_found for an unknown thread", async () => { const { agent, token } = await setup(["comment"]); const result = await agent.agentReply(token, { threadId: "nope", text: "x" }); - expect(result).toMatchObject({ error: { code: "doc_not_found" } }); + expect(result).toMatchObject({ error: { code: "thread_not_found" } }); + }); + + it("records a thread_reply event only when a reply is actually added, not on a resolve toggle", async () => { + const { agent, token } = await setup(["comment"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + const created = await agent.agentComment(token, { anchor, text: "needs work" }); + const threadId = "threadId" in created ? created.threadId : ""; + + const client = connectYjsClient(agent); + const threadsMap = client.doc.getMap("threads"); + + // Human resolves the thread (no reply added) — an untagged, human- + // origin edit to an agent-authored thread that must NOT be mistaken + // for a reply. + const beforeResolve = JSON.parse(threadsMap.get(threadId)!); + threadsMap.set(threadId, JSON.stringify({ ...beforeResolve, resolved: true })); + + const afterResolve = await agent.agentAwaitEvents(token, { timeoutMs: 20 }); + const eventsAfterResolve = "events" in afterResolve ? afterResolve.events : []; + expect(eventsAfterResolve.some((e) => e.type === "thread_reply")).toBe(false); + const cursorAfterResolve = "cursor" in afterResolve ? afterResolve.cursor : 0; + + // Now a real reply is added. + const beforeReply = JSON.parse(threadsMap.get(threadId)!); + beforeReply.replies.push({ + id: "r1", + author: { name: "Nick", color: "#000", colorLight: "#000" }, + text: "thanks!", + createdAt: Date.now(), + }); + threadsMap.set(threadId, JSON.stringify(beforeReply)); + + const afterReply = await agent.agentAwaitEvents(token, { cursor: cursorAfterResolve }); + const eventsAfterReply = "events" in afterReply ? afterReply.events : []; + const threadReplyEvents = eventsAfterReply.filter((e) => e.type === "thread_reply"); + expect(threadReplyEvents).toHaveLength(1); + expect(threadReplyEvents[0]).toMatchObject({ payload: { agent: "scribe", threadId } }); + + cleanup(client); + }); + + it("agentComment and agentReply are rate-limited like the other mutation RPCs", async () => { + const { agent, token } = await setup(["comment"]); + const read = await agent.agentRead(token); + const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; + + // Pre-fill this agent's rate-limit log at the per-minute mutation + // cap, driving checkRateLimit's denial path directly rather than via + // 10 real calls. + const tokenRows = mockTables.get("agent_tokens") ?? []; + const row = tokenRows.find((r) => r.name === "scribe")!; + const now = Date.now(); + row.recent_mutations = JSON.stringify( + Array.from({ length: 10 }, () => ({ at: now, chars: 1 })), + ); + + const commentResult = await agent.agentComment(token, { anchor, text: "hi" }); + expect(commentResult).toMatchObject({ error: { code: "rate_limited" } }); + + const replyResult = await agent.agentReply(token, { threadId: "whatever", text: "hi" }); + expect(replyResult).toMatchObject({ error: { code: "rate_limited" } }); }); it("prunes events on doc expiry (alarm)", async () => { From 539eab4ecc4d9b964f677f9807f03edda33c06d3 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 11:15:36 -0700 Subject: [PATCH 19/83] Serve MCP at /mcp backed by DocumentAgent RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a VaporMcp Durable Object that exposes the agent tool surface over streamable HTTP at /mcp. The tool table lives in agents/mcp-tools.ts, which imports nothing from the `agents` package so it stays testable in plain Vitest; agents/mcp.ts wires it to DocumentAgent stubs and adds create_document (no token, needs env). The bearer token from the Authorization header rides to the DO as ctx.props; DocumentAgent remains the only thing that validates it, and every tool — errors included — returns its result as JSON text content. Drops baseUrl from tsconfig.cloudflare.json: it resolved "agents/mcp" to this repo's own agents/ directory instead of the npm package. A paths entry keeps the package's internal types nameable. Co-Authored-By: Claude Fable 5 --- agents/mcp-tools.ts | 219 ++++++++++++++++++++++++++++ agents/mcp.ts | 97 ++++++++++++ package-lock.json | 4 +- package.json | 4 +- tests/unit/agents/mcp-tools.test.ts | 176 ++++++++++++++++++++++ tsconfig.cloudflare.json | 8 +- workers/app.ts | 24 +++ wrangler.jsonc | 6 +- 8 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 agents/mcp-tools.ts create mode 100644 agents/mcp.ts create mode 100644 tests/unit/agents/mcp-tools.test.ts diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts new file mode 100644 index 00000000..cc8daf2d --- /dev/null +++ b/agents/mcp-tools.ts @@ -0,0 +1,219 @@ +/** + * The MCP tool table: one entry per document tool in the agent-collaborators + * spec, each mapping tool arguments onto a `DocumentAgent` agent* RPC. + * + * This module deliberately imports nothing from the `agents` package (which + * uses `cloudflare:` protocol imports) so it stays unit-testable in plain + * Vitest. `agents/mcp.ts` supplies the real stubs and bearer token. + */ +import { z } from "zod"; +import { isValidDocumentId } from "../app/shared/constants"; +import type { AgentError } from "../app/shared/agent-protocol"; + +/** The subset of the DocumentAgent RPC surface the tools call. */ +export interface DocStub { + agentRead(token: string): Promise; + agentInsert(token: string, args: unknown): Promise; + agentReplace(token: string, args: unknown): Promise; + agentSuggest(token: string, args: unknown): Promise; + agentComment(token: string, args: unknown): Promise; + agentReply(token: string, args: unknown): Promise; + agentJoin(token: string, status?: string): Promise; + agentLeave(token: string): Promise; + agentAwaitEvents(token: string, args: unknown): Promise; +} + +export interface ToolDeps { + /** Resolves a document id to its DocumentAgent stub. */ + getStub(docId: string): Promise; + /** The bearer token presented on the MCP request. */ + token: string; +} + +/** A zod raw shape, as `McpServer.registerTool` accepts for `inputSchema`. */ +export type ToolSchema = Record; + +export interface ToolDef { + name: string; + description: string; + schema: ToolSchema; + run(deps: ToolDeps, args: Record): Promise; +} + +/** Errors are return values, never throws — same convention as the RPCs. */ +function errorResult(code: AgentError["code"], message: string): { error: AgentError } { + return { error: { code, message } }; +} + +const docId = z.string().describe("The 8-character document id (from its URL)."); +const pace = z + .enum(["natural", "fast", "instant"]) + .optional() + .describe("How the edit is performed: natural (human-paced typing), fast, or instant."); +const anchorDesc = "A block anchor from read_document, e.g. b3-a91f0c2d."; + +/** + * Builds a tool that resolves `doc_id` to a stub before calling an RPC. + * A malformed id is rejected without touching a Durable Object. + */ +function docTool(spec: { + name: string; + description: string; + schema: ToolSchema; + call(stub: DocStub, token: string, args: Record): Promise; +}): ToolDef { + return { + name: spec.name, + description: spec.description, + schema: { doc_id: docId, ...spec.schema }, + async run(deps, args) { + const id = args.doc_id; + if (typeof id !== "string" || !isValidDocumentId(id)) { + return errorResult("doc_not_found", `Not a valid document id: ${String(id)}`); + } + const stub = await deps.getStub(id); + return spec.call(stub, deps.token, args); + }, + }; +} + +export const TOOLS: ToolDef[] = [ + docTool({ + name: "read_document", + description: + "Read a vapor document: its full markdown, per-block anchors for editing, who is present, and open comment threads.", + schema: {}, + call: (stub, token) => stub.agentRead(token), + }), + + docTool({ + name: "insert", + description: + "Insert markdown as new blocks, before or after an anchored block, or appended to the end of the document. Requires the write capability.", + schema: { + anchor: z.string().optional().describe(`${anchorDesc} Required unless where is "append".`), + where: z.enum(["before", "after", "append"]).describe("Where to insert relative to anchor."), + markdown: z.string().describe("The markdown to insert."), + pace, + }, + call: (stub, token, args) => + stub.agentInsert(token, { + anchor: args.anchor as string | undefined, + where: args.where as "before" | "after" | "append", + markdown: args.markdown as string, + pace: args.pace as string | undefined, + }), + }), + + docTool({ + name: "replace", + description: + "Replace a range of blocks with new markdown, in one transaction. Requires the write capability.", + schema: { + from_anchor: z.string().describe(`First block to replace. ${anchorDesc}`), + to_anchor: z + .string() + .optional() + .describe(`Last block to replace; defaults to from_anchor. ${anchorDesc}`), + markdown: z.string().describe("The markdown that replaces the range."), + pace, + }, + call: (stub, token, args) => + stub.agentReplace(token, { + from: args.from_anchor as string, + to: args.to_anchor as string | undefined, + markdown: args.markdown as string, + pace: args.pace as string | undefined, + }), + }), + + docTool({ + name: "suggest", + description: + "Suggest a change inside a block as tracked CriticMarkup: find is marked deleted and replacement is marked added, for a human to accept or reject. Requires the suggest capability.", + schema: { + anchor: z.string().describe(anchorDesc), + find: z.string().describe("The exact text within that block to replace."), + replacement: z.string().describe("The suggested replacement text (empty string to delete)."), + pace, + }, + call: (stub, token, args) => + stub.agentSuggest(token, { + anchor: args.anchor as string, + find: args.find as string, + replacement: args.replacement as string, + pace: args.pace as string | undefined, + }), + }), + + docTool({ + name: "comment", + description: + "Open a comment thread anchored to a block. Requires the comment capability.", + schema: { + anchor: z.string().describe(anchorDesc), + quote: z.string().optional().describe("The text within the block the comment refers to."), + text: z.string().describe("The comment body."), + }, + call: (stub, token, args) => + stub.agentComment(token, { + anchor: args.anchor as string, + quote: args.quote as string | undefined, + text: args.text as string, + }), + }), + + docTool({ + name: "reply", + description: "Reply in an existing comment thread. Requires the comment capability.", + schema: { + thread_id: z.string().describe("The thread id, as returned by comment or read_document."), + text: z.string().describe("The reply body."), + }, + call: (stub, token, args) => + stub.agentReply(token, { + threadId: args.thread_id as string, + text: args.text as string, + }), + }), + + docTool({ + name: "join", + description: + "Appear in the document's presence stack as an agent, with an optional short activity status.", + schema: { + status: z.string().optional().describe('A short activity string, e.g. "drafting intro".'), + }, + call: (stub, token, args) => stub.agentJoin(token, args.status as string | undefined), + }), + + docTool({ + name: "leave", + description: "Remove this agent's presence from the document. The token stays valid.", + schema: {}, + call: (stub, token) => stub.agentLeave(token), + }), + + docTool({ + name: "await_events", + description: + "Long-poll for document events (mentions, thread replies, change digests) after a cursor. Returns as soon as anything is waiting, or empty when the timeout elapses.", + schema: { + since_cursor: z + .number() + .optional() + .describe("Return events after this cursor; omit to get everything so far."), + timeout_s: z + .number() + .optional() + .describe("How long to wait for an event, in seconds (max 50)."), + }, + call: (stub, token, args) => { + const timeoutS = args.timeout_s as number | undefined; + return stub.agentAwaitEvents(token, { + cursor: args.since_cursor as number | undefined, + timeoutMs: timeoutS === undefined ? undefined : timeoutS * 1000, + }); + }, + }), +]; diff --git a/agents/mcp.ts b/agents/mcp.ts new file mode 100644 index 00000000..837f35d3 --- /dev/null +++ b/agents/mcp.ts @@ -0,0 +1,97 @@ +/** + * The MCP server vapor exposes at /mcp. Each tool is backed by a + * `DocumentAgent` agent* RPC; the bearer token from the HTTP request arrives + * as `props.bearer` (set in workers/app.ts) and is passed straight through — + * the DocumentAgent is the only thing that validates it. + */ +import { McpAgent } from "agents/mcp"; +import { getAgentByName } from "agents"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { TOOLS, type DocStub } from "./mcp-tools"; +import { generateDocumentId } from "../app/shared/constants"; +import { deserializeThreads } from "../app/lib/thread-serialization"; + +export interface VaporMcpProps extends Record { + /** The Authorization: Bearer token, or null when none was presented. */ + bearer: string | null; + /** Origin of the MCP request, used to build document URLs. */ + origin?: string; +} + +const DEFAULT_ORIGIN = "https://vapor.fyi"; + +/** Every tool — errors included — returns its result as JSON text content. */ +function jsonContent(result: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; +} + +const INVALID_TOKEN = { + error: { + code: "invalid_token", + message: "Missing bearer token. Connect with Authorization: Bearer .", + }, +}; + +export class VaporMcp extends McpAgent { + server = new McpServer({ name: "vapor", version: "1.0.0" }); + + async init() { + for (const tool of TOOLS) { + this.server.registerTool( + tool.name, + { description: tool.description, inputSchema: tool.schema }, + async (args: Record) => { + const token = this.props?.bearer ?? null; + if (!token) return jsonContent(INVALID_TOKEN); + const result = await tool.run( + { + getStub: (docId) => + getAgentByName(this.env.DocumentAgent, docId) as unknown as Promise, + token, + }, + args, + ); + return jsonContent(result); + }, + ); + } + + // create_document needs env and no token, so it lives here rather than in + // the (deliberately dependency-free) tool table. + this.server.registerTool( + "create_document", + { + description: + "Create a new vapor document, optionally with starting markdown. Returns its id, URL, and a fresh agent token for it (suggest + comment capabilities).", + inputSchema: { + markdown: z.string().optional().describe("Optional starting markdown for the document."), + }, + }, + async ({ markdown }: { markdown?: string }) => { + const id = generateDocumentId(); + const stub = await getAgentByName(this.env.DocumentAgent, id); + + const init: RequestInit = { method: "POST" }; + if (markdown?.trim()) { + const { body, threads } = deserializeThreads(markdown); + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify({ content: body, threads }); + } + + const res = await stub.fetch(new Request("https://do/", init)); + if (!res.ok) { + return jsonContent({ + error: { code: "doc_not_found", message: "Failed to create document" }, + }); + } + + const minted = await stub.mintAgentToken({ name: "agent" }); + if ("error" in minted) return jsonContent(minted); + + const origin = this.props?.origin ?? DEFAULT_ORIGIN; + return jsonContent({ id, url: `${origin}/${id}`, token: minted.token }); + }, + ); + } +} diff --git a/package-lock.json b/package-lock.json index 7b94d0d1..9cfeb8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "mist", "hasInstallScript": true, "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-switch": "^1.2.6", "@tiptap/core": "^3.19.0", @@ -32,7 +33,8 @@ "sugar-high": "^1.1.0", "y-protocols": "^1.0.7", "yaml": "^2.8.2", - "yjs": "^13.6.29" + "yjs": "^13.6.29", + "zod": "^4.3.6" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.13.5", diff --git a/package.json b/package.json index 49158cb9..8d92575f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "typecheck": "npm run cf-typegen && react-router typegen && tsc -b" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-switch": "^1.2.6", "@tiptap/core": "^3.19.0", @@ -40,7 +41,8 @@ "sugar-high": "^1.1.0", "y-protocols": "^1.0.7", "yaml": "^2.8.2", - "yjs": "^13.6.29" + "yjs": "^13.6.29", + "zod": "^4.3.6" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.13.5", diff --git a/tests/unit/agents/mcp-tools.test.ts b/tests/unit/agents/mcp-tools.test.ts new file mode 100644 index 00000000..6e623ba9 --- /dev/null +++ b/tests/unit/agents/mcp-tools.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from "vitest"; +import { TOOLS } from "../../../agents/mcp-tools"; + +const SPEC_TOOLS = [ + "read_document", + "insert", + "replace", + "suggest", + "comment", + "reply", + "join", + "leave", + "await_events", +]; + +describe("mcp tool table", () => { + const names = TOOLS.map((t) => t.name); + + it("exposes the spec surface", () => { + for (const n of SPEC_TOOLS) expect(names).toContain(n); + }); + + it("gives every tool a description and a doc_id in its schema", () => { + for (const tool of TOOLS) { + expect(tool.description.length).toBeGreaterThan(0); + expect(tool.schema).toHaveProperty("doc_id"); + } + }); + + it("routes read_document to the stub with the bearer token", async () => { + const stub = { + agentRead: vi.fn(async () => ({ + markdown: "# Hi", + blocks: [], + presence: [], + threads: [], + })), + }; + const tool = TOOLS.find((t) => t.name === "read_document")!; + const out = await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234" }, + ); + expect(stub.agentRead).toHaveBeenCalledWith("vpr_t"); + expect(out).toMatchObject({ markdown: "# Hi" }); + }); + + it("rejects a malformed doc_id before touching a stub", async () => { + const getStub = vi.fn(); + const tool = TOOLS.find((t) => t.name === "read_document")!; + const out = await tool.run( + { getStub: getStub as never, token: "vpr_t" }, + { doc_id: "NOT-AN-ID" }, + ); + expect(getStub).not.toHaveBeenCalled(); + expect(out).toMatchObject({ error: { code: "doc_not_found" } }); + }); + + it("maps insert args onto agentInsert", async () => { + const stub = { agentInsert: vi.fn(async () => ({ ok: true })) }; + const tool = TOOLS.find((t) => t.name === "insert")!; + const out = await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234", anchor: "b1-aaaabbbb", where: "after", markdown: "hi", pace: "instant" }, + ); + expect(stub.agentInsert).toHaveBeenCalledWith("vpr_t", { + anchor: "b1-aaaabbbb", + where: "after", + markdown: "hi", + pace: "instant", + }); + expect(out).toEqual({ ok: true }); + }); + + it("maps replace's from_anchor/to_anchor onto agentReplace", async () => { + const stub = { agentReplace: vi.fn(async () => ({ ok: true })) }; + const tool = TOOLS.find((t) => t.name === "replace")!; + await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234", from_anchor: "b1-aaaabbbb", to_anchor: "b2-ccccdddd", markdown: "x" }, + ); + expect(stub.agentReplace).toHaveBeenCalledWith("vpr_t", { + from: "b1-aaaabbbb", + to: "b2-ccccdddd", + markdown: "x", + pace: undefined, + }); + }); + + it("maps suggest args onto agentSuggest", async () => { + const stub = { agentSuggest: vi.fn(async () => ({ ok: true })) }; + const tool = TOOLS.find((t) => t.name === "suggest")!; + await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234", anchor: "b1-aaaabbbb", find: "old", replacement: "new" }, + ); + expect(stub.agentSuggest).toHaveBeenCalledWith("vpr_t", { + anchor: "b1-aaaabbbb", + find: "old", + replacement: "new", + pace: undefined, + }); + }); + + it("maps comment and reply args onto their RPCs", async () => { + const stub = { + agentComment: vi.fn(async () => ({ threadId: "t1" })), + agentReply: vi.fn(async () => ({ ok: true })), + }; + const deps = { getStub: async () => stub as never, token: "vpr_t" }; + + const comment = await TOOLS.find((t) => t.name === "comment")!.run(deps, { + doc_id: "abcd1234", + anchor: "b1-aaaabbbb", + quote: "here", + text: "why?", + }); + expect(stub.agentComment).toHaveBeenCalledWith("vpr_t", { + anchor: "b1-aaaabbbb", + quote: "here", + text: "why?", + }); + expect(comment).toEqual({ threadId: "t1" }); + + await TOOLS.find((t) => t.name === "reply")!.run(deps, { + doc_id: "abcd1234", + thread_id: "t1", + text: "because", + }); + expect(stub.agentReply).toHaveBeenCalledWith("vpr_t", { threadId: "t1", text: "because" }); + }); + + it("maps join/leave onto presence RPCs", async () => { + const stub = { + agentJoin: vi.fn(async () => ({ ok: true })), + agentLeave: vi.fn(async () => ({ ok: true })), + }; + const deps = { getStub: async () => stub as never, token: "vpr_t" }; + + await TOOLS.find((t) => t.name === "join")!.run(deps, { + doc_id: "abcd1234", + status: "drafting", + }); + expect(stub.agentJoin).toHaveBeenCalledWith("vpr_t", "drafting"); + + await TOOLS.find((t) => t.name === "leave")!.run(deps, { doc_id: "abcd1234" }); + expect(stub.agentLeave).toHaveBeenCalledWith("vpr_t"); + }); + + it("converts await_events since_cursor/timeout_s to RPC args", async () => { + const stub = { agentAwaitEvents: vi.fn(async () => ({ events: [], cursor: 7 })) }; + const tool = TOOLS.find((t) => t.name === "await_events")!; + await tool.run( + { getStub: async () => stub as never, token: "vpr_t" }, + { doc_id: "abcd1234", since_cursor: 7, timeout_s: 30 }, + ); + expect(stub.agentAwaitEvents).toHaveBeenCalledWith("vpr_t", { + cursor: 7, + timeoutMs: 30_000, + }); + }); + + it("passes error results through untouched", async () => { + const stub = { + agentRead: vi.fn(async () => ({ + error: { code: "invalid_token", message: "Invalid or unknown agent token" }, + })), + }; + const tool = TOOLS.find((t) => t.name === "read_document")!; + const out = await tool.run( + { getStub: async () => stub as never, token: "nope" }, + { doc_id: "abcd1234" }, + ); + expect(out).toMatchObject({ error: { code: "invalid_token" } }); + }); +}); diff --git a/tsconfig.cloudflare.json b/tsconfig.cloudflare.json index 447ba0ab..9f0aaf3e 100644 --- a/tsconfig.cloudflare.json +++ b/tsconfig.cloudflare.json @@ -18,10 +18,14 @@ "module": "ES2022", "moduleResolution": "bundler", "jsx": "react-jsx", - "baseUrl": ".", + // No baseUrl: it would resolve bare specifiers like "agents/mcp" against + // this repo's own agents/ directory instead of the `agents` npm package. "rootDirs": [".", "./.react-router/types"], "paths": { - "~/*": ["./app/*"] + "~/*": ["./app/*"], + // Lets tsc name the agents package's internal types (which leak into + // inferred types, e.g. useAgent's return) without a baseUrl. + "agents/dist/*": ["./node_modules/agents/dist/*"] }, "esModuleInterop": true, "resolveJsonModule": true diff --git a/workers/app.ts b/workers/app.ts index 71e7e181..05d23761 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -1,16 +1,40 @@ import { createRequestHandler, RouterContextProvider } from "react-router"; import { routeAgentRequest } from "agents"; import { cloudflareContext } from "../app/lib/cloudflare.server"; +import { VaporMcp, type VaporMcpProps } from "../agents/mcp"; export { default as DocumentAgent } from "../agents/document"; +export { VaporMcp }; const requestHandler = createRequestHandler( () => import("virtual:react-router/server-build"), import.meta.env.MODE ); +const mcpHandler = VaporMcp.serve("/mcp", { binding: "VaporMcp" }); + export default { async fetch(request, env, ctx) { + const url = new URL(request.url); + + // The MCP server lives at /mcp (streamable HTTP). The bearer token rides + // along as props so the VaporMcp DO can pass it to DocumentAgent RPCs. + if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + const auth = request.headers.get("Authorization"); + const props: VaporMcpProps = { + bearer: auth?.startsWith("Bearer ") ? auth.slice(7) : null, + origin: url.origin, + }; + // ExecutionContext.props is readonly, so hand the MCP handler its own + // context carrying the props it plumbs through to the Durable Object. + const mcpCtx: ExecutionContext = { + props, + waitUntil: (promise) => ctx.waitUntil(promise), + passThroughOnException: () => ctx.passThroughOnException(), + }; + return mcpHandler.fetch(request, env, mcpCtx); + } + // routeAgentRequest will route to available agents using the // /agents/:agent/:name pattern, otherwise hand off to react-router const agentResponse = await routeAgentRequest(request, env); diff --git a/wrangler.jsonc b/wrangler.jsonc index 777947e1..f1df5bc5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,11 +10,13 @@ }, "durable_objects": { "bindings": [ - { "name": "DocumentAgent", "class_name": "DocumentAgent" } + { "name": "DocumentAgent", "class_name": "DocumentAgent" }, + { "name": "VaporMcp", "class_name": "VaporMcp" } ] }, "migrations": [ - { "tag": "v1", "new_sqlite_classes": ["DocumentAgent"] } + { "tag": "v1", "new_sqlite_classes": ["DocumentAgent"] }, + { "tag": "v2", "new_sqlite_classes": ["VaporMcp"] } ], "keep_vars": true } From a351ac33c1cf7a4553872d93edd5e116a9a3d340 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 11:25:48 -0700 Subject: [PATCH 20/83] Add raw markdown export and MCP help page Adds GET /:id.md for a document's raw markdown (public, no token, backed by a new DocumentAgent.exportMarkdown RPC) and a GET /mcp help page shown to browsers (Accept: text/html) instead of a protocol error, with connection snippets for Claude Code, claude.ai, and generic MCP clients. The two handlers live in workers/routes.ts as pure functions that avoid importing the `agents` package, so they unit-test in plain vitest. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 17 +++ app/lib/mcp-help.ts | 112 ++++++++++++++++++ .../integration/agents/document-agent.test.ts | 12 ++ tests/unit/agents/worker-routes.test.ts | 107 +++++++++++++++++ workers/app.ts | 22 +++- workers/routes.ts | 70 +++++++++++ 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 app/lib/mcp-help.ts create mode 100644 tests/unit/agents/worker-routes.test.ts create mode 100644 workers/routes.ts diff --git a/agents/document.ts b/agents/document.ts index d15a71be..a31410fd 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -815,6 +815,23 @@ class DocumentAgent extends Agent { return { markdown, blocks, presence, threads }; } + /** + * Returns the document's full markdown, with no token required — docs are + * public by URL, and this backs the public `GET /:id.md` raw export route + * (workers/routes.ts) as well as any future read-only surface that wants + * plain markdown without the anchors/presence/threads agentRead returns. + */ + async exportMarkdown(): Promise<{ markdown: string } | { error: AgentError }> { + this.ensureInitialised(); + + if (!this.docExists()) { + return { error: { code: "doc_not_found", message: "Document does not exist" } }; + } + + const { doc } = this.ensureInitialised(); + return { markdown: yDocToMarkdown(doc) }; + } + /** * Inserts markdown as new blocks. `where: "append"` needs no anchor; * otherwise the anchor is resolved and the blocks are inserted directly diff --git a/app/lib/mcp-help.ts b/app/lib/mcp-help.ts new file mode 100644 index 00000000..63b37487 --- /dev/null +++ b/app/lib/mcp-help.ts @@ -0,0 +1,112 @@ +/** + * The HTML help page served at `GET /mcp` when a browser asks for it + * (Accept: text/html) — API/MCP clients POST and never see this. Rendered by + * `workers/routes.ts`'s `handleMcpHelp`. + */ +export function mcpHelpHtml(origin: string): string { + const mcpUrl = `${origin}/mcp`; + const mcpServersJson = JSON.stringify( + { + mcpServers: { + vapor: { + url: mcpUrl, + headers: { + Authorization: "Bearer ", + }, + }, + }, + }, + null, + 2, + ); + + return ` + + + + +vapor MCP + + + +

vapor MCP

+

A Model Context Protocol server for editing vapor documents.

+ +

+ Every vapor document is a live, multiplayer markdown file. This MCP server lets an + agent read a document, insert or replace text, suggest tracked changes, comment, + and watch for mentions — the same document a person has open in their browser, + edited alongside them in real time. +

+ +

+ To connect, you need a document's agent token. Open the document, click + Invite agent, and mint one there — the token is shown once, so + copy it right away. +

+ +

Claude Code

+
claude mcp add --transport http vapor ${mcpUrl} --header "Authorization: Bearer <token>"
+ +

claude.ai

+

+ Go to Settings → Connectors → Add custom connector and paste this URL: +

+
${mcpUrl}
+

+ claude.ai will prompt you for the Authorization header — use + Bearer <token> with your document's token. +

+ +

Generic MCP client

+
${mcpServersJson}
+ +

+ Tokens are minted per document, from that document's Invite agent dialog — + there's no account or API key to set up separately. +

+ + +`; +} diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 36a0fa5c..a08e8d39 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -816,6 +816,18 @@ describe("DocumentAgent", () => { expect("blocks" in r && r.blocks[0].anchor).toMatch(/^b0-[0-9a-f]{8}$/); }); + it("exportMarkdown returns the document's markdown with no token", async () => { + const { agent } = await setup(); + const r = await agent.exportMarkdown(); + expect(r).toEqual({ markdown: "# Title\n\nBody." }); + }); + + it("exportMarkdown errors doc_not_found for a document never created", async () => { + const agent = makeAgent(); + const r = await agent.exportMarkdown(); + expect(r).toMatchObject({ error: { code: "doc_not_found" } }); + }); + it("denies write without capability, allows with it", async () => { const { agent, token } = await setup(); // default: no write const denied = await agent.agentInsert(token, { where: "append", markdown: "More." }); diff --git a/tests/unit/agents/worker-routes.test.ts b/tests/unit/agents/worker-routes.test.ts new file mode 100644 index 00000000..e13112a4 --- /dev/null +++ b/tests/unit/agents/worker-routes.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleRawMarkdown, handleMcpHelp } from "../../../workers/routes"; + +describe("handleRawMarkdown", () => { + it("returns 200 with text/markdown for an existing doc", async () => { + const stub = { + exportMarkdown: vi.fn(async () => ({ markdown: "# Hello\n\nWorld" })), + }; + const getStub = vi.fn(async () => stub); + + const res = await handleRawMarkdown( + new Request("https://vapor.fyi/abcd1234.md"), + getStub, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get("Content-Type")).toBe("text/markdown; charset=utf-8"); + expect(await res!.text()).toBe("# Hello\n\nWorld"); + expect(getStub).toHaveBeenCalledWith("abcd1234"); + }); + + it("returns 404 for a valid-format id whose document doesn't exist", async () => { + const stub = { + exportMarkdown: vi.fn(async () => ({ + error: { code: "doc_not_found" as const, message: "Document does not exist" }, + })), + }; + const getStub = vi.fn(async () => stub); + + const res = await handleRawMarkdown( + new Request("https://vapor.fyi/zzzz9999.md"), + getStub, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(404); + }); + + it("returns null for an invalid document id", async () => { + const getStub = vi.fn(); + + const res = await handleRawMarkdown(new Request("https://vapor.fyi/foo.md"), getStub); + + expect(res).toBeNull(); + expect(getStub).not.toHaveBeenCalled(); + }); + + it("returns null for a non-.md path", async () => { + const getStub = vi.fn(); + + const res = await handleRawMarkdown(new Request("https://vapor.fyi/abcd1234"), getStub); + + expect(res).toBeNull(); + expect(getStub).not.toHaveBeenCalled(); + }); + + it("returns null for non-GET requests", async () => { + const getStub = vi.fn(); + + const res = await handleRawMarkdown( + new Request("https://vapor.fyi/abcd1234.md", { method: "POST" }), + getStub, + ); + + expect(res).toBeNull(); + expect(getStub).not.toHaveBeenCalled(); + }); +}); + +describe("handleMcpHelp", () => { + it("returns an HTML help page for a browser GET", async () => { + const res = handleMcpHelp( + new Request("https://vapor.fyi/mcp", { headers: { Accept: "text/html" } }), + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get("Content-Type")).toContain("text/html"); + const body = await res!.text(); + expect(body).toContain("claude mcp add"); + }); + + it("returns null when Accept is application/json (MCP clients)", () => { + const res = handleMcpHelp( + new Request("https://vapor.fyi/mcp", { headers: { Accept: "application/json" } }), + ); + + expect(res).toBeNull(); + }); + + it("returns null for a non-/mcp path", () => { + const res = handleMcpHelp( + new Request("https://vapor.fyi/other", { headers: { Accept: "text/html" } }), + ); + + expect(res).toBeNull(); + }); + + it("returns null for non-GET requests", () => { + const res = handleMcpHelp( + new Request("https://vapor.fyi/mcp", { method: "POST", headers: { Accept: "text/html" } }), + ); + + expect(res).toBeNull(); + }); +}); diff --git a/workers/app.ts b/workers/app.ts index 05d23761..c826dcbd 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -1,7 +1,8 @@ import { createRequestHandler, RouterContextProvider } from "react-router"; -import { routeAgentRequest } from "agents"; +import { routeAgentRequest, getAgentByName } from "agents"; import { cloudflareContext } from "../app/lib/cloudflare.server"; import { VaporMcp, type VaporMcpProps } from "../agents/mcp"; +import { handleRawMarkdown, handleMcpHelp, type MarkdownStub } from "./routes"; export { default as DocumentAgent } from "../agents/document"; export { VaporMcp }; @@ -17,6 +18,25 @@ export default { async fetch(request, env, ctx) { const url = new URL(request.url); + // A browser landing on /mcp (Accept: text/html) gets a how-to-connect + // page instead of a protocol error. MCP clients send an + // application/json-flavoured Accept and never match this, so they fall + // through to VaporMcp.serve below. Must run before that branch. + const helpResponse = handleMcpHelp(request); + if (helpResponse) { + return helpResponse; + } + + // GET /:id.md serves a document's raw markdown, public by URL like the + // rest of vapor. Falls through (null) for anything that isn't that + // shape, so it must run before routeAgentRequest/React Router. + const markdownResponse = await handleRawMarkdown(request, (id) => + getAgentByName(env.DocumentAgent, id) as unknown as Promise, + ); + if (markdownResponse) { + return markdownResponse; + } + // The MCP server lives at /mcp (streamable HTTP). The bearer token rides // along as props so the VaporMcp DO can pass it to DocumentAgent RPCs. if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { diff --git a/workers/routes.ts b/workers/routes.ts new file mode 100644 index 00000000..1a87d090 --- /dev/null +++ b/workers/routes.ts @@ -0,0 +1,70 @@ +/** + * Pure request handlers for the two browser-facing routes bolted onto the + * worker outside of routeAgentRequest/React Router: raw markdown export and + * the /mcp help page. Deliberately does not import the `agents` package (its + * `cloudflare:` protocol imports don't exist in plain Vitest) — `getStub` is + * injected from workers/app.ts instead, which does have that import, so this + * module stays unit-testable. + */ +import { isValidDocumentId } from "../app/shared/constants"; +import type { AgentError } from "../app/shared/agent-protocol"; +import { mcpHelpHtml } from "../app/lib/mcp-help"; + +/** The subset of the DocumentAgent RPC surface handleRawMarkdown calls. */ +export interface MarkdownStub { + exportMarkdown(): Promise<{ markdown: string } | { error: AgentError }>; +} + +/** + * `GET /:id.md` — a document's full markdown as `text/markdown`, public by + * URL like the rest of vapor (no token). Returns null (letting the worker + * fall through to the next route) for anything that isn't a GET on a + * `/<8-char-id>.md` path; 404 for a valid-format id whose document doesn't + * exist. + */ +export async function handleRawMarkdown( + request: Request, + getStub: (id: string) => Promise, +): Promise { + if (request.method !== "GET") return null; + + const url = new URL(request.url); + const match = /^\/([^/]+)\.md$/.exec(url.pathname); + if (!match) return null; + + const id = match[1]; + if (!isValidDocumentId(id)) return null; + + const stub = await getStub(id); + const result = await stub.exportMarkdown(); + if ("error" in result) { + return new Response("Not found", { status: 404 }); + } + + return new Response(result.markdown, { + status: 200, + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +} + +/** + * `GET /mcp` with `Accept: text/html` — a browser landing on the MCP + * endpoint gets a how-to-connect page instead of a protocol error. MCP + * clients POST with an `application/json`-flavoured Accept header, so they + * never match this and fall through to `VaporMcp.serve`. Must be checked + * before that branch in workers/app.ts. + */ +export function handleMcpHelp(request: Request): Response | null { + if (request.method !== "GET") return null; + + const url = new URL(request.url); + if (url.pathname !== "/mcp") return null; + + const accept = request.headers.get("Accept") ?? ""; + if (!accept.includes("text/html")) return null; + + return new Response(mcpHelpHtml(url.origin), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} From 7b8a56f53fc2a7a7a0756c1c904e7178f3bdfe29 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 11:35:32 -0700 Subject: [PATCH 21/83] Fix unescaped MCP help origin and add nosniff to markdown export Restricts the origin interpolated into the /mcp help page to a strict http(s) allowlist, falling back to the default origin otherwise, since it derives from the client-controlled Host header and was being spliced unescaped into raw HTML/JSON. Also adds X-Content-Type-Options: nosniff to the new GET /:id.md response, since it serves raw user content at a public URL. Co-Authored-By: Claude Fable 5 --- app/lib/mcp-help.ts | 18 ++++++++++++++++- tests/unit/agents/worker-routes.test.ts | 1 + tests/unit/lib/mcp-help.test.ts | 26 +++++++++++++++++++++++++ workers/routes.ts | 7 ++++++- 4 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/unit/lib/mcp-help.test.ts diff --git a/app/lib/mcp-help.ts b/app/lib/mcp-help.ts index 63b37487..e5b8ed60 100644 --- a/app/lib/mcp-help.ts +++ b/app/lib/mcp-help.ts @@ -3,8 +3,24 @@ * (Accept: text/html) — API/MCP clients POST and never see this. Rendered by * `workers/routes.ts`'s `handleMcpHelp`. */ + +/** Fallback used whenever `origin` doesn't look like a plain http(s) origin. */ +const DEFAULT_ORIGIN = "https://vapor.fyi"; + +/** + * `origin` comes from `url.origin` in workers/routes.ts, which derives from + * the client-controlled Host header — it is interpolated unescaped into raw + * HTML below (a `
` block and a JSON literal), so a crafted Host like
+ * `https://evil"';
+    const html = mcpHelpHtml(hostile);
+
+    expect(html.toLowerCase()).not.toContain(" {
+    const html = mcpHelpHtml("javascript:alert(1)");
+    expect(html).toContain("https://vapor.fyi/mcp");
+    expect(html).not.toContain("javascript:alert(1)");
+  });
+});
diff --git a/workers/routes.ts b/workers/routes.ts
index 1a87d090..06b02a92 100644
--- a/workers/routes.ts
+++ b/workers/routes.ts
@@ -43,7 +43,12 @@ export async function handleRawMarkdown(
 
   return new Response(result.markdown, {
     status: 200,
-    headers: { "Content-Type": "text/markdown; charset=utf-8" },
+    headers: {
+      "Content-Type": "text/markdown; charset=utf-8",
+      // Raw, user-authored content served at a public URL — don't let a
+      // browser sniff it into something more dangerous than markdown.
+      "X-Content-Type-Options": "nosniff",
+    },
   });
 }
 

From e583b550eb041e1d827273c51358c258d44a12bd Mon Sep 17 00:00:00 2001
From: Nicholas Jitkoff 
Date: Sun, 30 Aug 2026 11:50:20 -0700
Subject: [PATCH 22/83] Add invite agent dialog and roster management

Adds the /:id/agents resource route (GET roster, POST mint/revoke)
backed by DocumentAgent's mintAgentToken/getAgentRoster/revokeAgentToken
RPCs, with server-side capability validation since the route is the
untyped boundary. Wires an InviteAgentDialog into the doc header: a
form with a pre-filled unused name suggestion, capability switches
(suggest+comment on, write off by default), a one-time token screen
with copy-ready Claude Code/claude.ai/mcpServers snippets, and a live
roster list with revoke.

Co-Authored-By: Claude Fable 5 
---
 app/components/InviteAgentDialog.tsx          | 375 ++++++++++++++++++
 app/routes.ts                                 |   1 +
 app/routes/doc.$id.agents.ts                  | 153 +++++++
 app/routes/doc.$id.tsx                        |   4 +
 .../components/InviteAgentDialog.test.tsx     | 139 +++++++
 tests/unit/routes/doc-agents-route.test.ts    | 183 +++++++++
 6 files changed, 855 insertions(+)
 create mode 100644 app/components/InviteAgentDialog.tsx
 create mode 100644 app/routes/doc.$id.agents.ts
 create mode 100644 tests/unit/components/InviteAgentDialog.test.tsx
 create mode 100644 tests/unit/routes/doc-agents-route.test.ts

diff --git a/app/components/InviteAgentDialog.tsx b/app/components/InviteAgentDialog.tsx
new file mode 100644
index 00000000..9d12df0b
--- /dev/null
+++ b/app/components/InviteAgentDialog.tsx
@@ -0,0 +1,375 @@
+import { useState, useEffect, useCallback, useId } from "react";
+import * as Switch from "@radix-ui/react-switch";
+import { useDocument } from "~/lib/DocumentContext";
+import {
+  AGENT_NAME_RE,
+  DEFAULT_CAPABILITIES,
+  type AgentCapability,
+  type AgentRosterEntry,
+} from "~/shared/agent-protocol";
+
+const CAPABILITY_ORDER: AgentCapability[] = ["suggest", "comment", "write"];
+const CAPABILITY_LABELS: Record = {
+  suggest: "Suggest",
+  comment: "Comment",
+  write: "Write",
+};
+
+const NAME_SUGGESTIONS = [
+  "scribe",
+  "muse",
+  "echo",
+  "quill",
+  "sage",
+  "nova",
+  "atlas",
+  "juniper",
+  "orbit",
+  "flux",
+];
+
+function pickUnusedName(taken: Set): string {
+  for (const candidate of NAME_SUGGESTIONS) {
+    if (!taken.has(candidate)) return candidate;
+  }
+  return `agent-${Math.random().toString(36).slice(2, 6)}`;
+}
+
+function relativeTime(ts: number | null): string {
+  if (ts == null) return "never";
+  const diffMs = Date.now() - ts;
+  if (diffMs < 60_000) return "just now";
+  const mins = Math.floor(diffMs / 60_000);
+  if (mins < 60) return `${mins}m ago`;
+  const hours = Math.floor(mins / 60);
+  if (hours < 24) return `${hours}h ago`;
+  const days = Math.floor(hours / 24);
+  return `${days}d ago`;
+}
+
+interface MintedAgent {
+  token: string;
+  entry: AgentRosterEntry;
+}
+
+interface ErrorBody {
+  error: { message: string };
+}
+
+function switchClass() {
+  return "inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-border transition-colors data-[state=checked]:bg-coral";
+}
+
+function thumbClass() {
+  return "pointer-events-none block h-5 w-5 rounded-full bg-paper shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0";
+}
+
+export default function InviteAgentDialog() {
+  const { docId } = useDocument();
+  const [open, setOpen] = useState(false);
+  const [roster, setRoster] = useState([]);
+  const [name, setName] = useState("");
+  const [owner, setOwner] = useState("");
+  const [capabilities, setCapabilities] = useState>(
+    () => new Set(DEFAULT_CAPABILITIES),
+  );
+  const [nameError, setNameError] = useState(null);
+  const [minted, setMinted] = useState(null);
+  const [copiedField, setCopiedField] = useState(null);
+  const nameInputId = useId();
+  const ownerInputId = useId();
+
+  const loadRoster = useCallback(async () => {
+    const res = await fetch(`/${docId}/agents`);
+    if (!res.ok) return;
+    const list = (await res.json()) as AgentRosterEntry[];
+    setRoster(list);
+  }, [docId]);
+
+  // Fetches the roster from the server when the dialog opens; the setState
+  // happens after the await, not synchronously in the effect body.
+  useEffect(() => {
+    if (!open) return;
+    // eslint-disable-next-line react-hooks/set-state-in-effect
+    void loadRoster();
+  }, [open, loadRoster]);
+
+  // Derives the pre-filled name suggestion from the freshly loaded roster;
+  // only runs once per dialog open (guarded by the `current` check).
+  useEffect(() => {
+    if (!open || minted) return;
+    const taken = new Set(roster.map((r) => r.name));
+    // eslint-disable-next-line react-hooks/set-state-in-effect
+    setName((current) => (current ? current : pickUnusedName(taken)));
+  }, [open, roster, minted]);
+
+  function handleOpen() {
+    setMinted(null);
+    setName("");
+    setOwner("");
+    setNameError(null);
+    setCopiedField(null);
+    setCapabilities(new Set(DEFAULT_CAPABILITIES));
+    setOpen(true);
+  }
+
+  function handleClose() {
+    setOpen(false);
+  }
+
+  function toggleCapability(cap: AgentCapability) {
+    setCapabilities((prev) => {
+      const next = new Set(prev);
+      if (next.has(cap)) next.delete(cap);
+      else next.add(cap);
+      return next;
+    });
+  }
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!AGENT_NAME_RE.test(name)) {
+      setNameError("Lowercase letters, digits, and hyphens");
+      return;
+    }
+    setNameError(null);
+
+    const res = await fetch(`/${docId}/agents`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        intent: "mint",
+        name,
+        owner: owner.trim() || undefined,
+        capabilities: [...capabilities],
+      }),
+    });
+    const json = (await res.json()) as MintedAgent | ErrorBody;
+    if (!res.ok || "error" in json) {
+      setNameError("error" in json ? json.error.message : "Failed to create agent");
+      return;
+    }
+    setMinted(json);
+    void loadRoster();
+  }
+
+  async function handleRevoke(revokeName: string) {
+    await fetch(`/${docId}/agents`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ intent: "revoke", name: revokeName }),
+    });
+    void loadRoster();
+  }
+
+  async function handleCopy(field: string, text: string) {
+    await navigator.clipboard.writeText(text);
+    setCopiedField(field);
+    setTimeout(() => setCopiedField((f) => (f === field ? null : f)), 2000);
+  }
+
+  const origin = typeof window !== "undefined" ? window.location.origin : "";
+  const claudeCodeCommand = minted
+    ? `claude mcp add --transport http vapor ${origin}/mcp --header "Authorization: Bearer ${minted.token}"`
+    : "";
+  const connectorUrl = `${origin}/mcp`;
+  const mcpServersJson = minted
+    ? JSON.stringify(
+        {
+          mcpServers: {
+            vapor: {
+              url: `${origin}/mcp`,
+              headers: { Authorization: `Bearer ${minted.token}` },
+            },
+          },
+        },
+        null,
+        2,
+      )
+    : "";
+
+  return (
+    <>
+      
+      {open && (
+        
+
+
+

Invite agent

+ +
+ + {!minted ? ( +
+
+ + setName(e.target.value)} + className="w-full border border-border bg-paper px-3 py-1.5 font-mono text-sm outline-none focus:border-ink" + /> + {nameError &&

{nameError}

} +
+
+ + setOwner(e.target.value)} + className="w-full border border-border bg-paper px-3 py-1.5 text-sm outline-none focus:border-ink" + /> +
+
+ {CAPABILITY_ORDER.map((cap) => ( +
+ {CAPABILITY_LABELS[cap]} + toggleCapability(cap)} + aria-label={CAPABILITY_LABELS[cap]} + className={switchClass()} + > + + +
+ ))} +
+ +
+ ) : ( +
+ + {minted.token} + + +

+ This token is shown once. Revoke and re-mint to replace it. +

+
+ + + +
+
+ )} + +
+

Roster

+ {roster.length === 0 ? ( +

No agents invited yet.

+ ) : ( +
    + {roster.map((entry) => ( +
  • + + + {entry.name} + {entry.capabilities.map((c) => ( + + {c} + + ))} + {entry.owner && {entry.owner}} + {relativeTime(entry.lastSeenAt)} + + +
  • + ))} +
+ )} +
+
+
+ )} + + ); +} + +function SnippetRow({ + label, + text, + field, + copiedField, + onCopy, +}: { + label: string; + text: string; + field: string; + copiedField: string | null; + onCopy: (field: string, text: string) => void; +}) { + return ( +
+
+ {label} + +
+
+        {text}
+      
+
+ ); +} diff --git a/app/routes.ts b/app/routes.ts index 1b52e66b..1bea35c9 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,5 +3,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("new", "routes/new.ts"), + route(":id/agents", "routes/doc.$id.agents.ts"), route(":id", "routes/doc.$id.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/doc.$id.agents.ts b/app/routes/doc.$id.agents.ts new file mode 100644 index 00000000..00bd8cb2 --- /dev/null +++ b/app/routes/doc.$id.agents.ts @@ -0,0 +1,153 @@ +import { getAgentByName } from "agents"; +import type { Route } from "./+types/doc.$id.agents"; +import { isValidDocumentId } from "~/shared/constants"; +import { getCloudflare } from "~/lib/cloudflare.server"; +import type { + AgentCapability, + AgentError, + AgentErrorCode, + AgentRosterEntry, +} from "~/shared/agent-protocol"; + +/** The subset of the DocumentAgent RPC surface this route calls. */ +interface AgentStub { + mintAgentToken(opts: { + name: string; + owner?: string; + capabilities?: AgentCapability[]; + }): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }>; + getAgentRoster(): Promise; + revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }>; +} + +const KNOWN_CAPABILITIES: AgentCapability[] = ["comment", "suggest", "write"]; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function notFound() { + return jsonResponse(null, 404); +} + +function badRequest(message: string) { + return jsonResponse({ error: { message } }, 400); +} + +/** + * Maps a DocumentAgent `AgentError` onto an HTTP status. Only + * `mintAgentToken`/`revokeAgentToken` errors reach this route today + * (doc_not_found, invalid_name), but the mapping covers the full + * `AgentErrorCode` union so a future RPC error doesn't fall through + * unmapped. + */ +function statusForErrorCode(code: AgentErrorCode): number { + switch (code) { + case "doc_not_found": + case "doc_expired": + case "thread_not_found": + case "find_not_matched": + return 404; + case "invalid_token": + return 401; + case "capability_denied": + return 403; + case "rate_limited": + return 429; + case "stale_anchor": + return 409; + case "invalid_name": + default: + return 400; + } +} + +async function getStub(context: Route.LoaderArgs["context"], id: string): Promise { + const { env } = getCloudflare(context); + return (await getAgentByName(env.DocumentAgent, id)) as unknown as AgentStub; +} + +export async function loader({ params, context }: Route.LoaderArgs) { + const id = params.id; + if (!isValidDocumentId(id)) { + return notFound(); + } + + const stub = await getStub(context, id); + const roster = await stub.getAgentRoster(); + return jsonResponse(roster); +} + +export async function action({ params, context, request }: Route.ActionArgs) { + const id = params.id; + if (!isValidDocumentId(id)) { + return notFound(); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return badRequest("Invalid JSON body"); + } + + if (typeof body !== "object" || body === null) { + return badRequest("Invalid body"); + } + + const stub = await getStub(context, id); + const record = body as Record; + + if (record.intent === "mint") { + const name = record.name; + if (typeof name !== "string") { + return badRequest("name is required"); + } + + const ownerRaw = record.owner; + const owner = + typeof ownerRaw === "string" && ownerRaw.trim() ? ownerRaw : undefined; + + let capabilities: AgentCapability[] | undefined; + if (record.capabilities !== undefined) { + const capsRaw = record.capabilities; + const isValid = + Array.isArray(capsRaw) && + capsRaw.every( + (c): c is AgentCapability => + typeof c === "string" && + KNOWN_CAPABILITIES.includes(c as AgentCapability), + ); + if (!isValid) { + return badRequest( + `capabilities must be a subset of ${KNOWN_CAPABILITIES.join(", ")}`, + ); + } + capabilities = capsRaw as AgentCapability[]; + } + + const result = await stub.mintAgentToken({ name, owner, capabilities }); + if ("error" in result) { + return jsonResponse(result, statusForErrorCode(result.error.code)); + } + return jsonResponse(result, 201); + } + + if (record.intent === "revoke") { + const name = record.name; + if (typeof name !== "string") { + return badRequest("name is required"); + } + + const result = await stub.revokeAgentToken(name); + if ("error" in result) { + return jsonResponse(result, statusForErrorCode(result.error.code)); + } + return jsonResponse(result); + } + + return badRequest("Unknown intent"); +} diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index 1637fadf..be5ee48a 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -10,6 +10,7 @@ import Preview from "~/components/Preview"; import PreviewToggle from "~/components/PreviewToggle"; import ConnectionStatus from "~/components/ConnectionStatus"; import ShareButton from "~/components/ShareButton"; +import InviteAgentDialog from "~/components/InviteAgentDialog"; import ModeToggle from "~/components/ModeToggle"; import CleanViewToggle from "~/components/CleanViewToggle"; import SuggestionActions from "~/components/SuggestionActions"; @@ -103,6 +104,9 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul
+
+ +
diff --git a/tests/unit/components/InviteAgentDialog.test.tsx b/tests/unit/components/InviteAgentDialog.test.tsx new file mode 100644 index 00000000..d65d3c60 --- /dev/null +++ b/tests/unit/components/InviteAgentDialog.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createElement } from "react"; +import { fireEvent, waitFor, act } from "@testing-library/react"; +import { renderWithDocument } from "../../helpers/document-context"; +import InviteAgentDialog from "~/components/InviteAgentDialog"; + +const rosterEntry = { + name: "scribe", + color: "#E57373", + owner: null, + capabilities: ["suggest", "comment"], + createdAt: Date.now(), + lastSeenAt: null, +}; + +function mockFetchSequence(responses: Array<{ body: unknown; status?: number }>) { + const fn = vi.fn(); + for (const { body, status = 200 } of responses) { + fn.mockImplementationOnce( + () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as Promise, + ); + } + return fn; +} + +// jsdom has no ResizeObserver; @radix-ui/react-switch's useSize hook needs one +// for its Thumb. Real behaviour doesn't depend on actual measurements here. +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe("InviteAgentDialog", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + vi.stubGlobal("ResizeObserver", MockResizeObserver); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens with default capability switches: suggest+comment on, write off", async () => { + global.fetch = mockFetchSequence([{ body: [] }]); + + const { getByText, getByRole } = renderWithDocument( + createElement(InviteAgentDialog), + ); + + fireEvent.click(getByText("Invite agent")); + + await waitFor(() => { + expect(getByRole("switch", { name: /suggest/i })).toBeTruthy(); + }); + + expect(getByRole("switch", { name: /suggest/i }).getAttribute("data-state")).toBe( + "checked", + ); + expect(getByRole("switch", { name: /comment/i }).getAttribute("data-state")).toBe( + "checked", + ); + expect(getByRole("switch", { name: /write/i }).getAttribute("data-state")).toBe( + "unchecked", + ); + }); + + it("shows inline validation error for an invalid name", async () => { + global.fetch = mockFetchSequence([{ body: [] }]); + + const { getByText, getByLabelText } = renderWithDocument( + createElement(InviteAgentDialog), + ); + + fireEvent.click(getByText("Invite agent")); + await waitFor(() => getByLabelText("Name")); + + const nameInput = getByLabelText("Name") as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Not Valid!" } }); + fireEvent.click(getByText("Create")); + + await waitFor(() => { + expect(getByText("Lowercase letters, digits, and hyphens")).toBeTruthy(); + }); + }); + + it("submits the typed name and shows the token screen once", async () => { + global.fetch = mockFetchSequence([ + { body: [] }, + { + body: { token: "secret-once-token", entry: rosterEntry }, + status: 201, + }, + { body: [rosterEntry] }, + ]); + + const { getByText, getByLabelText } = renderWithDocument( + createElement(InviteAgentDialog), + { context: { docId: "abcd1234" } }, + ); + + fireEvent.click(getByText("Invite agent")); + await waitFor(() => getByLabelText("Name")); + + const nameInput = getByLabelText("Name") as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "muse" } }); + + await act(async () => { + fireEvent.click(getByText("Create")); + }); + + await waitFor(() => { + expect(getByText("secret-once-token")).toBeTruthy(); + }); + + expect( + getByText("This token is shown once. Revoke and re-mint to replace it."), + ).toBeTruthy(); + + const postCall = (global.fetch as ReturnType).mock.calls.find( + ([, init]: [unknown, RequestInit | undefined]) => init?.method === "POST", + ); + expect(postCall).toBeTruthy(); + const [url, init] = postCall as [string, RequestInit]; + expect(url).toBe("/abcd1234/agents"); + const parsedBody = JSON.parse(init.body as string); + expect(parsedBody).toMatchObject({ intent: "mint", name: "muse" }); + }); +}); diff --git a/tests/unit/routes/doc-agents-route.test.ts b/tests/unit/routes/doc-agents-route.test.ts new file mode 100644 index 00000000..97ea817e --- /dev/null +++ b/tests/unit/routes/doc-agents-route.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +const { mockMint, mockRoster, mockRevoke } = vi.hoisted(() => ({ + mockMint: vi.fn(), + mockRoster: vi.fn(), + mockRevoke: vi.fn(), +})); + +vi.mock("agents", () => ({ + getAgentByName: vi.fn().mockResolvedValue({ + mintAgentToken: mockMint, + getAgentRoster: mockRoster, + revokeAgentToken: mockRevoke, + }), +})); + +vi.mock("~/lib/cloudflare.server", () => ({ + getCloudflare: vi.fn().mockReturnValue({ + env: { DocumentAgent: {} }, + }), +})); + +import { action, loader } from "~/routes/doc.$id.agents"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const context = {} as Parameters[0]["context"]; + +function loaderArgs(id: string) { + return { + params: { id }, + context, + request: new Request(`https://mist.example.com/${id}/agents`), + } as unknown as Parameters[0]; +} + +function actionArgs(id: string, body: unknown) { + return { + params: { id }, + context, + request: new Request(`https://mist.example.com/${id}/agents`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + } as unknown as Parameters[0]; +} + +const rosterEntry = { + name: "scribe", + color: "#E57373", + owner: null, + capabilities: ["suggest", "comment"], + createdAt: 1000, + lastSeenAt: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe("GET /:id/agents (loader)", () => { + it("returns 404 for an invalid document id", async () => { + const response = (await loader(loaderArgs("bad"))) as Response; + expect(response.status).toBe(404); + }); + + it("returns the roster as JSON", async () => { + mockRoster.mockResolvedValue([rosterEntry]); + const response = (await loader(loaderArgs("abcd1234"))) as Response; + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual([rosterEntry]); + }); +}); + +describe("POST /:id/agents (action)", () => { + it("returns 404 for an invalid document id", async () => { + const response = (await action( + actionArgs("bad", { intent: "mint", name: "scribe" }), + )) as Response; + expect(response.status).toBe(404); + }); + + it("mints a token and returns it exactly once", async () => { + mockMint.mockResolvedValue({ token: "secret-token", entry: rosterEntry }); + const response = (await action( + actionArgs("abcd1234", { intent: "mint", name: "scribe" }), + )) as Response; + + expect(response.status).toBe(201); + const json = await response.json(); + expect(json).toEqual({ token: "secret-token", entry: rosterEntry }); + expect(mockMint).toHaveBeenCalledWith({ + name: "scribe", + owner: undefined, + capabilities: undefined, + }); + }); + + it("passes owner and capabilities through to mintAgentToken", async () => { + mockMint.mockResolvedValue({ token: "t", entry: rosterEntry }); + await action( + actionArgs("abcd1234", { + intent: "mint", + name: "scribe", + owner: "nicholas", + capabilities: ["write"], + }), + ); + expect(mockMint).toHaveBeenCalledWith({ + name: "scribe", + owner: "nicholas", + capabilities: ["write"], + }); + }); + + it("rejects capabilities outside the known set", async () => { + const response = (await action( + actionArgs("abcd1234", { + intent: "mint", + name: "scribe", + capabilities: ["write", "admin"], + }), + )) as Response; + expect(response.status).toBe(400); + expect(mockMint).not.toHaveBeenCalled(); + }); + + it("returns 400 with the DO error for invalid_name", async () => { + mockMint.mockResolvedValue({ + error: { code: "invalid_name", message: "Agent name already taken: scribe" }, + }); + const response = (await action( + actionArgs("abcd1234", { intent: "mint", name: "scribe" }), + )) as Response; + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.error.code).toBe("invalid_name"); + }); + + it("returns 404 with the DO error for doc_not_found", async () => { + mockMint.mockResolvedValue({ + error: { code: "doc_not_found", message: "Document does not exist" }, + }); + const response = (await action( + actionArgs("abcd1234", { intent: "mint", name: "scribe" }), + )) as Response; + expect(response.status).toBe(404); + }); + + it("revokes a token", async () => { + mockRevoke.mockResolvedValue({ ok: true }); + const response = (await action( + actionArgs("abcd1234", { intent: "revoke", name: "scribe" }), + )) as Response; + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ ok: true }); + expect(mockRevoke).toHaveBeenCalledWith("scribe"); + }); + + it("returns 400 for an unknown intent", async () => { + const response = (await action(actionArgs("abcd1234", { intent: "bogus" }))) as Response; + expect(response.status).toBe(400); + }); + + it("returns 400 for a missing name on mint", async () => { + const response = (await action(actionArgs("abcd1234", { intent: "mint" }))) as Response; + expect(response.status).toBe(400); + expect(mockMint).not.toHaveBeenCalled(); + }); +}); From 3628c0e1db452fdd364ed627ea22cb0c29489b56 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 11:59:27 -0700 Subject: [PATCH 23/83] Fix invite agent dialog accessibility Adds role="dialog"/aria-modal/aria-labelledby, Escape-to-close, a backdrop-click-only close (ignoring bubbled clicks from the panel), and minimal focus management (focus the name input on open, return focus to the invoking button on close). No dialog primitive exists in this codebase to build on, so this is the manual minimal version. Co-Authored-By: Claude Fable 5 --- app/components/InviteAgentDialog.tsx | 61 ++++++++++++--- .../components/InviteAgentDialog.test.tsx | 74 +++++++++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/app/components/InviteAgentDialog.tsx b/app/components/InviteAgentDialog.tsx index 9d12df0b..6d5c1326 100644 --- a/app/components/InviteAgentDialog.tsx +++ b/app/components/InviteAgentDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useId } from "react"; +import { useState, useEffect, useCallback, useId, useRef } from "react"; import * as Switch from "@radix-ui/react-switch"; import { useDocument } from "~/lib/DocumentContext"; import { @@ -78,6 +78,9 @@ export default function InviteAgentDialog() { const [copiedField, setCopiedField] = useState(null); const nameInputId = useId(); const ownerInputId = useId(); + const titleId = useId(); + const triggerRef = useRef(null); + const nameInputRef = useRef(null); const loadRoster = useCallback(async () => { const res = await fetch(`/${docId}/agents`); @@ -90,7 +93,6 @@ export default function InviteAgentDialog() { // happens after the await, not synchronously in the effect body. useEffect(() => { if (!open) return; - // eslint-disable-next-line react-hooks/set-state-in-effect void loadRoster(); }, [open, loadRoster]); @@ -99,11 +101,10 @@ export default function InviteAgentDialog() { useEffect(() => { if (!open || minted) return; const taken = new Set(roster.map((r) => r.name)); - // eslint-disable-next-line react-hooks/set-state-in-effect setName((current) => (current ? current : pickUnusedName(taken))); }, [open, roster, minted]); - function handleOpen() { + const handleOpen = useCallback(() => { setMinted(null); setName(""); setOwner(""); @@ -111,10 +112,40 @@ export default function InviteAgentDialog() { setCopiedField(null); setCapabilities(new Set(DEFAULT_CAPABILITIES)); setOpen(true); - } + }, []); - function handleClose() { + const handleClose = useCallback(() => { setOpen(false); + // Return focus to the menu item that opened the dialog. + triggerRef.current?.focus(); + }, []); + + // Focuses the name input as soon as the dialog (in its default, unminted + // form) mounts, so keyboard users land somewhere useful instead of on the + // document body. + useEffect(() => { + if (!open || minted) return; + nameInputRef.current?.focus(); + // Only on the open transition — refocusing on every keystroke re-render + // would fight the user. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Escape closes the dialog, same as the overlay-click / close-button paths. + useEffect(() => { + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") handleClose(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, handleClose]); + + // Closes only on a genuine backdrop click — a click that bubbles up from + // the panel itself has `e.target` set to the descendant it started on, not + // the overlay, so it's ignored here. + function handleOverlayClick(e: React.MouseEvent) { + if (e.target === e.currentTarget) handleClose(); } function toggleCapability(cap: AgentCapability) { @@ -191,16 +222,27 @@ export default function InviteAgentDialog() { return ( <> {open && ( -
-
+
+
-

Invite agent

+

+ Invite agent +

"'; const html = mcpHelpHtml(hostile); diff --git a/tests/unit/shared/agent-protocol.test.ts b/tests/unit/shared/agent-protocol.test.ts index 80dd227d..ac30ed2c 100644 --- a/tests/unit/shared/agent-protocol.test.ts +++ b/tests/unit/shared/agent-protocol.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { blockHash, formatAnchor, parseAnchor, findMentions, AGENT_NAME_RE, - RESERVED_SLUGS, isReservedSlug, + RESERVED_SLUGS, isReservedSlug, slugifyAgentName, } from "~/shared/agent-protocol"; describe("blockHash", () => { @@ -62,3 +62,47 @@ describe("reserved slugs", () => { expect(isReservedSlug("newx1234")).toBe(false); }); }); + +describe("slugifyAgentName", () => { + it("lowercases and passes through an already-valid slug", () => { + expect(slugifyAgentName("Claude Code")).toBe("claude-code"); + expect(slugifyAgentName("nicks-agent")).toBe("nicks-agent"); + }); + + it("collapses runs of symbols and spaces into single hyphens", () => { + expect(slugifyAgentName("Test Client!!")).toBe("test-client"); + expect(slugifyAgentName("my_cool.agent@v2")).toBe("my-cool-agent-v2"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugifyAgentName("--edge--")).toBe("edge"); + }); + + it("falls back to agent for empty or symbol-only input", () => { + expect(slugifyAgentName("")).toBe("agent"); + expect(slugifyAgentName("!!!")).toBe("agent"); + expect(slugifyAgentName(" ")).toBe("agent"); + }); + + it("falls back to agent for a single character (below AGENT_NAME_RE's minimum)", () => { + expect(slugifyAgentName("a")).toBe("agent"); + }); + + it("clamps to 32 characters and never leaves a dangling hyphen", () => { + const long = "a".repeat(40); + const slug = slugifyAgentName(long); + expect(slug.length).toBeLessThanOrEqual(32); + expect(AGENT_NAME_RE.test(slug)).toBe(true); + + const longWithBoundaryHyphen = "b".repeat(31) + "-" + "c".repeat(10); + const slug2 = slugifyAgentName(longWithBoundaryHyphen); + expect(slug2.length).toBeLessThanOrEqual(32); + expect(AGENT_NAME_RE.test(slug2)).toBe(true); + }); + + it("always returns a string matching AGENT_NAME_RE", () => { + for (const input of ["Claude Code", "", "a", "!!!", "A".repeat(50), " -- "]) { + expect(AGENT_NAME_RE.test(slugifyAgentName(input))).toBe(true); + } + }); +}); From fd3a556e90fb9b378295e91ed27b044fec824179 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 14:35:40 -0700 Subject: [PATCH 37/83] Derive create_document's minted agent name from clientInfo too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_document's fresh token was always minted under the literal name "agent", even though the tokenless anonymous-tool path derives its base name from the MCP client's declared clientInfo.name (slugified) — visible asymmetry between vapor's two tokenless-agent-naming paths with no exemption in the design doc. create_document now uses the same rule via a new createDocumentAgentName helper in agents/mcp-tools.ts (unit-tested there); no retry-with-suffix loop is needed since the document is brand new and its roster can't yet collide. Also documents the +8 headroom on MAX_ANONYMOUS_NAME_ATTEMPTS in agents/document.ts. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 5 ++++- agents/mcp-tools.ts | 17 ++++++++++++++++- agents/mcp.ts | 13 +++++++++++-- tests/unit/agents/mcp-tools.test.ts | 15 ++++++++++++++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index cd36a466..672cc3eb 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -47,7 +47,10 @@ const AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; * Upper bound on name-collision retries in enrollAnonymousAgent (base, * base-2, base-3, …). Comfortably above MAX_AGENTS_PER_DOC so a full roster * of same-named clients still gets a shot at every suffix before the cap - * itself (not exhausted attempts) is what stops enrollment. + * itself (not exhausted attempts) is what stops enrollment. The `+ 8` is + * just headroom — a token or two may get revoked and re-minted with the + * same base name between attempts, so a margin past the cap avoids a + * spurious failure right at the boundary; it isn't tied to any other limit. */ const MAX_ANONYMOUS_NAME_ATTEMPTS = MAX_AGENTS_PER_DOC + 8; diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index 899d88ba..13406d50 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -8,7 +8,7 @@ */ import { z } from "zod"; import { isValidDocumentId } from "../app/shared/constants"; -import type { AgentError, AgentRosterEntry } from "../app/shared/agent-protocol"; +import { slugifyAgentName, type AgentError, type AgentRosterEntry } from "../app/shared/agent-protocol"; /** The subset of the DocumentAgent RPC surface the tools call. */ export interface DocStub { @@ -76,6 +76,21 @@ export function validateNewDocumentMarkdown( return null; } +/** + * The base agent name create_document mints its fresh token under, derived + * from the connecting MCP client's declared name — the same rule the + * anonymous tool path uses (agents/mcp-anonymous.ts) for the same reason: + * "agent" for every client made every doc's first collaborator look + * identical, with no way to tell which client created it. Since the + * document is brand new, there's no roster to collide with, so (unlike + * enrollAnonymousAgent) no retry-with-suffix loop is needed. Lives here + * (rather than inline in agents/mcp.ts, which can't be imported in plain + * Vitest) so the naming rule is unit-testable directly. + */ +export function createDocumentAgentName(clientName: string | undefined): string { + return slugifyAgentName(clientName ?? "agent"); +} + const docId = z.string().describe("The 8-character document id (from its URL)."); const pace = z .enum(["natural", "fast", "instant"]) diff --git a/agents/mcp.ts b/agents/mcp.ts index a22fa357..94b7f567 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -14,7 +14,12 @@ import { McpAgent } from "agents/mcp"; import { getAgentByName } from "agents"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { TOOLS, validateNewDocumentMarkdown, type DocStub } from "./mcp-tools"; +import { + TOOLS, + validateNewDocumentMarkdown, + createDocumentAgentName, + type DocStub, +} from "./mcp-tools"; import { runAnonymousTool, type AnonymousAgentState } from "./mcp-anonymous"; import { generateDocumentId } from "../app/shared/constants"; import { slugifyAgentName } from "../app/shared/agent-protocol"; @@ -108,7 +113,11 @@ export class VaporMcp extends McpAgent }); } - const minted = await stub.mintAgentToken({ name: "agent" }); + // Same clientInfo-derived naming as the anonymous tool path, for the + // same reason: the doc is brand new, so there's no roster to + // collide with and no retry loop is needed. + const clientInfo = this.server.server.getClientVersion(); + const minted = await stub.mintAgentToken({ name: createDocumentAgentName(clientInfo?.name) }); if ("error" in minted) return jsonContent(minted); // create_document is tokenless for everyone, but an anonymous diff --git a/tests/unit/agents/mcp-tools.test.ts b/tests/unit/agents/mcp-tools.test.ts index 01d7d8d2..92ce5013 100644 --- a/tests/unit/agents/mcp-tools.test.ts +++ b/tests/unit/agents/mcp-tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { TOOLS, validateNewDocumentMarkdown } from "../../../agents/mcp-tools"; +import { TOOLS, validateNewDocumentMarkdown, createDocumentAgentName } from "../../../agents/mcp-tools"; const SPEC_TOOLS = [ "read_document", @@ -194,3 +194,16 @@ describe("validateNewDocumentMarkdown", () => { }); }); }); + +describe("createDocumentAgentName", () => { + it("derives create_document's minted agent name from the client's declared name", () => { + expect(createDocumentAgentName("Claude Code")).toBe("claude-code"); + expect(createDocumentAgentName("Second Session Client")).toBe("second-session-client"); + }); + + it("falls back to agent when the client name is absent or unusable", () => { + expect(createDocumentAgentName(undefined)).toBe("agent"); + expect(createDocumentAgentName("")).toBe("agent"); + expect(createDocumentAgentName("!!!")).toBe("agent"); + }); +}); From dcbdb454e526b87057effff21c8b66b136e30f4c Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Sun, 30 Aug 2026 14:55:46 -0700 Subject: [PATCH 38/83] Ignore .superpowers scratch directory Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf624a96..7218776b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ worker-configuration.d.ts # Coverage /coverage/ +.superpowers/ From c80c2a0de0326b003c29fe0c7ae6db811f29b658 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:26:58 -0700 Subject: [PATCH 39/83] Add identity phase design spec Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-30-identity-design.md | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/plans/2026-08-30-identity-design.md diff --git a/docs/plans/2026-08-30-identity-design.md b/docs/plans/2026-08-30-identity-design.md new file mode 100644 index 00000000..fa55dc74 --- /dev/null +++ b/docs/plans/2026-08-30-identity-design.md @@ -0,0 +1,89 @@ +# Identity phase — design + +Vapor gains optional user identity: Google sign-in on the web, OAuth for MCP clients, and counterpart agents bound to their owners. The architecture is a port of subpixel's proven stack (see `~/Code/subpixel/server/auth.ts`, `oauth.ts`, `registry.ts`) — same account, same conventions, battle-tested code. + +Decisions settled in discussion, 2026-08-30: + +- **Provider**: Google only this phase (GSI credential flow — client-side ID token, server-side WebCrypto verification against Google's JWKS, no client secret, no auth library). GitHub/Apple/magic-links later; subpixel's issuer-table sketch is the extension path. +- **Identity = verified email principal** (`email:`), exactly subpixel's model. A stable random `uid` decouples storage from the principal. +- **Sign-in stays optional, everywhere.** Public-by-URL, anonymous editing, and anonymous MCP are unchanged. Identity buys attribution and counterpart agents — never a wall. +- **Identity ≠ access control this phase.** No ACLs, no private docs. Owner fields get real values; enforcement comes later. +- **Storage**: one global `Registry` Durable Object (`idFromName("global")`), SQLite-backed, prefixed key namespaces — no D1, no KV. Matches vapor's DO-native architecture and subpixel's reference implementation. + +## Components + +``` +Browser ──GSI credential──► POST /auth/google ──verify──► session cookie (vp_session) +MCP client ──OAuth 2.1 (PKCE)──► /oauth/* ──consent──► access token = short-lived session JWT + │ + ▼ + Registry DO ("global") + profiles · agent slugs · oauth clients/codes/refresh tokens + │ principal flows via props + ▼ +VaporMcp ──RPC──► DocumentAgent (roster entries gain owner = principal) +``` + +- **`server-side auth module`** (`app/lib/auth.server.ts` + `workers/` wiring): ported from subpixel `server/auth.ts`. Google ID-token verification (JWKS via Cache API), HS256 session JWT signer/verifier (WebCrypto HMAC, `SESSION_SECRET`), cookie (`vp_session`, HttpOnly, SameSite=Lax, Secure, 30-day TTL) + `Authorization: Bearer` fallback, same-origin guard on credential posts. +- **`Registry` DO** (`agents/registry.ts`): profiles keyed `p:` → `{ uid, displayName, avatar, agentSlug }`; reverse indexes `u:`, `a:`. Also owns OAuth AS state: registered clients, auth codes, refresh tokens (prefixed namespaces, subpixel pattern). +- **OAuth 2.1 authorization server** (`workers/oauth.ts`, port of subpixel `server/oauth.ts`): PKCE S256, dynamic client registration, RFC 8414/9728 discovery documents, consent page, refresh. Access token = 1-hour session JWT carrying `{ principal, email, caps }`; refresh token rotates in the Registry. No `workers-oauth-provider` — consistency with subpixel beats the library. + +## Routes + +| Route | Purpose | +|---|---| +| `GET /auth/config` | public Google client id | +| `POST /auth/google` | verify GSI credential → set session cookie | +| `GET /auth/me` | current session (principal, displayName, agentSlug) | +| `POST /auth/logout` | clear cookie | +| `GET/POST /oauth/authorize`, `POST /oauth/token`, `POST /oauth/register`, `POST /oauth/revoke` | MCP OAuth AS | +| `GET /.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource` | discovery | + +Reserved-slug list gains `auth`, `oauth`, `.well-known` (already covered), `settings`. + +## The two MCP doors + +MCP clients discover OAuth via a 401 challenge — but vapor's `/mcp` must never 401, because anonymous access is a feature. So identity gets its own door: + +- **`/mcp`** — unchanged. Tokenless → anonymous agent; bearer → per-doc token. Never challenges. +- **`/mcp/me`** — identical tool surface, but requires an OAuth access token: unauthenticated requests get `401` + `WWW-Authenticate` with the resource-metadata URL, which is what makes Claude Code and claude.ai run the consent flow automatically. The token's `principal` flows into `VaporMcp` props. + +Adding `https://vapor.fyi/mcp` stays the zero-friction door; adding `https://vapor.fyi/mcp/me` gets a browser consent pop and a durable identity. The `/mcp` help page explains both. + +## Consent and capabilities + +The consent page (server-rendered, GSI inline — subpixel's `consentPage` pattern) shows the requesting client's name and a capability choice: + +- **Suggest & comment** (default, pre-selected) — the counterpart argues, humans decide. +- **Full write** — explicit opt-in, one extra click. + +Granted caps ride in the access token. Rationale: the org's agents-suggest-by-default posture, applied at the identity level. + +## Counterpart agents + +One standing agent identity per user: + +- **`agentSlug`**: auto-derived at first grant — `slugifyAgentName(displayName)`, uniquified globally in the Registry (`-2`, `-3`, …). User-editable later (settings page is out of scope this phase). +- On any `/mcp/me` tool call touching a doc, `VaporMcp` enrolls (or reuses) a roster entry: `name = agentSlug`, `owner = principal`, capabilities = the grant's caps, token server-held per (principal, doc) in the Registry — the session-state pattern from anonymous agents, but durable and cross-session. +- The roster UI shows the owner; the caret badge is unchanged. Revoke in a doc severs that doc's entry only; the OAuth grant itself is revoked via `/oauth/revoke` or a future settings page. +- Invariant: counterpart capabilities ≤ the grant's caps ≤ what any URL-holder could do anyway (all docs world-editable this phase), preserving the no-escalation argument. + +## Web sign-in + +- A **Sign in** affordance in the doc header (GSI button in a small popover; subpixel's `web/js/auth.js` is the reference). Optional forever. +- Signed-in presence: awareness `user.name` = displayName (replacing "User 397"); comments authored with displayName. Anonymous users keep the current behavior. +- No handle system this phase — displayName from Google suffices for attribution; `agentSlug` covers the machine-name need. + +## Secrets + +`SESSION_SECRET` (new, `wrangler secret put`), `GOOGLE_CLIENT_ID` (public, plain var). Google Cloud console setup: one OAuth client id for vapor.fyi (+ localhost for dev). Per org standards, values live in Workers secrets and the vault; `.dev.vars.example` gains the names. + +## Out of scope (recorded so they stay out) + +ACLs/private docs, doc ownership enforcement, handle claiming UI, settings page, personal API tokens for headless agents (per-doc tokens cover them meanwhile), GitHub/Apple/magic-link providers, ADMIN_EMAILS-gated features, extracting a shared auth package for subpixel+vapor (candidate follow-up once both run the ported code). + +## Testing + +- **Unit**: session JWT round-trip + expiry + tamper rejection; Google ID-token verification against a fixture JWKS (subpixel's test approach); slug uniquification; OAuth code/PKCE verifier checks; consent-cap encoding. +- **Integration**: Registry DO profile round-trip via the mock-Agent pattern; `/mcp/me` 401-challenge shape; grant → counterpart enrollment → roster owner set; anonymous `/mcp` completely unaffected (regression). +- **Live acceptance**: add `vapor.fyi/mcp/me` in Claude Code → browser consent → suggest lands as `` owned by the signed-in principal; sign in on the web → presence shows displayName. From d34f7a52971df13be1350ae197acd0d3dfaada2b Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:26:58 -0700 Subject: [PATCH 40/83] Make /mcp the authenticated door, /mcp/anonymous the tokenless one Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-30-identity-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-08-30-identity-design.md b/docs/plans/2026-08-30-identity-design.md index fa55dc74..fc3f1dcb 100644 --- a/docs/plans/2026-08-30-identity-design.md +++ b/docs/plans/2026-08-30-identity-design.md @@ -43,12 +43,12 @@ Reserved-slug list gains `auth`, `oauth`, `.well-known` (already covered), `sett ## The two MCP doors -MCP clients discover OAuth via a 401 challenge — but vapor's `/mcp` must never 401, because anonymous access is a feature. So identity gets its own door: +Identity is the default; anonymity is the explicitly chosen door: -- **`/mcp`** — unchanged. Tokenless → anonymous agent; bearer → per-doc token. Never challenges. -- **`/mcp/me`** — identical tool surface, but requires an OAuth access token: unauthenticated requests get `401` + `WWW-Authenticate` with the resource-metadata URL, which is what makes Claude Code and claude.ai run the consent flow automatically. The token's `principal` flows into `VaporMcp` props. +- **`/mcp`** — the primary endpoint, now credential-bearing. Accepts either an OAuth access token (identity path) or an existing per-doc `vpr_` bearer token (Invite-agent flow, unchanged — a request carrying any credential is never challenged). A request with **no** credential gets `401` + `WWW-Authenticate` with the resource-metadata URL — which is exactly what makes Claude Code and claude.ai run the browser consent flow automatically. Adding `https://vapor.fyi/mcp` now means signing in. +- **`/mcp/anonymous`** — identical tool surface, never challenges. Tokenless → auto-enrolled anonymous agent (current behavior, relocated). The zero-friction door for people who don't want an account, and the connector URL the help page offers second, not first. -Adding `https://vapor.fyi/mcp` stays the zero-friction door; adding `https://vapor.fyi/mcp/me` gets a browser consent pop and a durable identity. The `/mcp` help page explains both. +Migration note: tokenless clients already connected to `/mcp` will start receiving the OAuth challenge and be walked into consent — the intended nudge. Per-doc-token clients are unaffected. The `/mcp` help page and README lead with the signed-in door and mention `/mcp/anonymous` as the alternative. ## Consent and capabilities @@ -86,4 +86,4 @@ ACLs/private docs, doc ownership enforcement, handle claiming UI, settings page, - **Unit**: session JWT round-trip + expiry + tamper rejection; Google ID-token verification against a fixture JWKS (subpixel's test approach); slug uniquification; OAuth code/PKCE verifier checks; consent-cap encoding. - **Integration**: Registry DO profile round-trip via the mock-Agent pattern; `/mcp/me` 401-challenge shape; grant → counterpart enrollment → roster owner set; anonymous `/mcp` completely unaffected (regression). -- **Live acceptance**: add `vapor.fyi/mcp/me` in Claude Code → browser consent → suggest lands as `` owned by the signed-in principal; sign in on the web → presence shows displayName. +- **Live acceptance**: add `vapor.fyi/mcp` in Claude Code → browser consent → suggest lands as `` owned by the signed-in principal; `vapor.fyi/mcp/anonymous` still connects with zero configuration; sign in on the web → presence shows displayName. From e6dd4aaef8e422d01520adc7c75b898a4b4934a4 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:26:58 -0700 Subject: [PATCH 41/83] Retire per-doc tokens in identity spec; identity becomes the credential Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-30-identity-design.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-30-identity-design.md b/docs/plans/2026-08-30-identity-design.md index fc3f1dcb..06b3ad38 100644 --- a/docs/plans/2026-08-30-identity-design.md +++ b/docs/plans/2026-08-30-identity-design.md @@ -45,10 +45,20 @@ Reserved-slug list gains `auth`, `oauth`, `.well-known` (already covered), `sett Identity is the default; anonymity is the explicitly chosen door: -- **`/mcp`** — the primary endpoint, now credential-bearing. Accepts either an OAuth access token (identity path) or an existing per-doc `vpr_` bearer token (Invite-agent flow, unchanged — a request carrying any credential is never challenged). A request with **no** credential gets `401` + `WWW-Authenticate` with the resource-metadata URL — which is exactly what makes Claude Code and claude.ai run the browser consent flow automatically. Adding `https://vapor.fyi/mcp` now means signing in. +- **`/mcp`** — the primary endpoint. Accepts exactly one credential type: an OAuth access token. A request with no (or an invalid) credential gets `401` + `WWW-Authenticate` with the resource-metadata URL — which is exactly what makes Claude Code and claude.ai run the browser consent flow automatically. Adding `https://vapor.fyi/mcp` means signing in. - **`/mcp/anonymous`** — identical tool surface, never challenges. Tokenless → auto-enrolled anonymous agent (current behavior, relocated). The zero-friction door for people who don't want an account, and the connector URL the help page offers second, not first. -Migration note: tokenless clients already connected to `/mcp` will start receiving the OAuth challenge and be walked into consent — the intended nudge. Per-doc-token clients are unaffected. The `/mcp` help page and README lead with the signed-in door and mention `/mcp/anonymous` as the alternative. +Migration note: this is a deliberate breaking change for existing `/mcp` clients — tokenless ones get walked into consent (the intended nudge), and `vpr_` bearer holders are cut off (see below). The help page and README lead with the signed-in door and mention `/mcp/anonymous` as the alternative. + +## Per-doc tokens retire + +User-facing `vpr_` tokens are removed — they were the identity stopgap, and OAuth replaces them (Nicholas approved the break: no users to migrate). + +- **Invite agent dialog** shrinks to what it should have been: connection instructions (the two doors) plus the roster with revoke. No minting, no one-time token screen, no capability switches — capabilities now live on the OAuth grant. +- **Write capability** is granted at consent time, per user, instead of per doc. Per-doc revoke survives via the roster (severing that doc's enrollment); revoking the grant itself kills the counterpart everywhere. +- **`create_document`** returns id + URL only — the calling identity (principal or anonymous session) is already enrolled on the new doc; no token in the response. +- **Headless agents** (CI, scripts) use `/mcp/anonymous` (suggest + comment), or complete one browser consent and hold the refresh token; personal API tokens return later if that pinches. +- **Internally**, `DocumentAgent`'s roster and RPC surface migrate from raw-token arguments to a verified identity argument (`{ kind: "principal" | "anonymous", id, caps }`) passed by `VaporMcp` after it has authenticated the caller — the `agent_tokens` hashing machinery goes away entirely rather than lingering as plumbing. Rate limits key on the identity instead of the token hash. ## Consent and capabilities @@ -64,7 +74,7 @@ Granted caps ride in the access token. Rationale: the org's agents-suggest-by-de One standing agent identity per user: - **`agentSlug`**: auto-derived at first grant — `slugifyAgentName(displayName)`, uniquified globally in the Registry (`-2`, `-3`, …). User-editable later (settings page is out of scope this phase). -- On any `/mcp/me` tool call touching a doc, `VaporMcp` enrolls (or reuses) a roster entry: `name = agentSlug`, `owner = principal`, capabilities = the grant's caps, token server-held per (principal, doc) in the Registry — the session-state pattern from anonymous agents, but durable and cross-session. +- On any authenticated `/mcp` tool call touching a doc, `VaporMcp` enrolls (or reuses) a roster entry: `name = agentSlug`, `owner = principal`, capabilities = the grant's caps. No tokens involved — the verified identity is the credential, so enrollment is durable and cross-session by construction. - The roster UI shows the owner; the caret badge is unchanged. Revoke in a doc severs that doc's entry only; the OAuth grant itself is revoked via `/oauth/revoke` or a future settings page. - Invariant: counterpart capabilities ≤ the grant's caps ≤ what any URL-holder could do anyway (all docs world-editable this phase), preserving the no-escalation argument. @@ -85,5 +95,5 @@ ACLs/private docs, doc ownership enforcement, handle claiming UI, settings page, ## Testing - **Unit**: session JWT round-trip + expiry + tamper rejection; Google ID-token verification against a fixture JWKS (subpixel's test approach); slug uniquification; OAuth code/PKCE verifier checks; consent-cap encoding. -- **Integration**: Registry DO profile round-trip via the mock-Agent pattern; `/mcp/me` 401-challenge shape; grant → counterpart enrollment → roster owner set; anonymous `/mcp` completely unaffected (regression). +- **Integration**: Registry DO profile round-trip via the mock-Agent pattern; `/mcp` 401-challenge shape (bare and invalid-credential requests); grant → counterpart enrollment → roster owner set; `/mcp/anonymous` behaves exactly as today's tokenless `/mcp` (regression); DocumentAgent RPCs accept the verified-identity argument and reject malformed ones. - **Live acceptance**: add `vapor.fyi/mcp` in Claude Code → browser consent → suggest lands as `` owned by the signed-in principal; `vapor.fyi/mcp/anonymous` still connects with zero configuration; sign in on the web → presence shows displayName. From c027aa02f1f4db439da84896c9da427df6fdab5d Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:30:26 -0700 Subject: [PATCH 42/83] Add identity phase implementation plan Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-30-identity-plan.md | 156 +++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/plans/2026-08-30-identity-plan.md diff --git a/docs/plans/2026-08-30-identity-plan.md b/docs/plans/2026-08-30-identity-plan.md new file mode 100644 index 00000000..8cbb3867 --- /dev/null +++ b/docs/plans/2026-08-30-identity-plan.md @@ -0,0 +1,156 @@ +# Identity Phase Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Optional Google identity for vapor — web sign-in, OAuth-gated `/mcp` with automatic client consent, `/mcp/anonymous` for tokenless access, counterpart agents owned by principals, and full retirement of per-doc `vpr_` tokens. + +**Architecture:** Port subpixel's dependency-free auth stack (Google ID-token verification, HMAC session JWTs, hand-rolled OAuth 2.1 AS) into vapor. A new global `Registry` DO holds profiles + OAuth state. `DocumentAgent`'s RPC surface migrates from raw tokens to a verified-identity argument; `VaporMcp` authenticates callers and passes identity down. Spec: `docs/plans/2026-08-30-identity-design.md`. Port sources (read, then adapt — same owner, no license concerns; keep a pointer comment): `~/Code/subpixel/server/auth.ts` (381 lines), `oauth.ts` (355), `registry.ts` (802). + +**Tech Stack:** Cloudflare Workers + DOs, WebCrypto (RS256 verify, HMAC HS256), Agents SDK, React Router 7, Vitest. + +## Global Constraints + +- Nothing under `app/` imports from `agents/`; `agents/` may import `app/lib`/`app/shared`. `workers/routes.ts` and new `workers/oauth.ts` stay free of the `agents` npm package (dependency-injected) so they unit-test in plain Vitest. +- DO integration tests use the mock-Agent pattern in `tests/integration/agents/` (extend the sql/state fakes as needed). +- Errors from DocumentAgent RPCs are return values `{ error: { code, message } }`, never throws. +- Session cookie name `vp_session`; secrets `SESSION_SECRET` (Workers secret) + `GOOGLE_CLIENT_ID` (plain var); `.dev.vars.example` gains both names. +- Verified identity type (single source of truth, `app/shared/agent-protocol.ts`): + ```ts + export interface AgentIdentity { + kind: "principal" | "anonymous"; + id: string; // principal ("email:…") or anonymous session key + name: string; // roster/display slug (agentSlug or slugified clientInfo) + owner: string | null; // principal for kind=principal, null for anonymous + caps: AgentCapability[]; + } + ``` +- Anonymous capabilities stay `DEFAULT_CAPABILITIES`; principal caps come from the OAuth grant. +- Reserved slugs gain `auth`, `oauth`, `settings` (`.well-known` already present). +- ESLint `_` prefix; TS strict; commits imperative with the `Co-Authored-By: Claude Fable 5 ` trailer; run `npm run typecheck && npm run lint && npx vitest run tests` before each commit. +- BREAKING is fine (approved): `vpr_` tokens, mint/one-time-token UI, and `agent_tokens` machinery are deleted, not deprecated. + +--- + +### Task 1: Port the auth core + +**Files:** +- Create: `app/lib/auth.server.ts` (port of subpixel `server/auth.ts` — sessions, Google verify, cookies) +- Modify: `app/shared/agent-protocol.ts` (add `AgentIdentity`, reserved slugs), `.dev.vars.example` +- Test: `tests/unit/lib/auth-server.test.ts` + +**Interfaces (produces):** +```ts +export interface SessionClaims { principal: string; email: string; caps?: AgentCapability[]; iat: number; exp: number; } +export async function mintSessionToken(claims: Omit, secret: string, ttlSeconds?: number): Promise; +export async function verifySessionToken(token: string, secret: string): Promise; +export async function verifyGoogleIdToken(credential: string, clientId: string): Promise<{ email: string; name: string; picture?: string } | null>; // RS256 vs Google JWKS, cached via caches.default; injectable JWKS fetcher for tests +export function sessionFromRequest(req: Request, secret: string): Promise; // vp_session cookie OR Authorization: Bearer +export function sessionCookieHeader(token: string, maxAge: number, secure: boolean): string; // HttpOnly; SameSite=Lax; Path=/ +export function principalFromEmail(email: string): string; // "email:" + lowercased +``` +Adapt from subpixel: rename cookie `sp_session`→`vp_session`; keep the same-origin guard helper; drop Playdate device-pairing entirely; make the JWKS fetch injectable (`(url) => Promise`) so tests use a fixture keypair generated with WebCrypto in the test itself (sign a fake ID token with the fixture private key; verify against the fixture JWKS). + +- [ ] **Step 1:** Failing unit tests: session mint→verify round-trip; expired token → null; tampered payload → null; `verifyGoogleIdToken` accepts a fixture-signed token with correct aud/iss/exp and rejects wrong-aud, wrong-iss, expired, bad-signature; cookie header shape; `principalFromEmail("Foo@Bar.COM") === "email:foo@bar.com"`. +- [ ] **Step 2:** RED → port/implement → GREEN. Add `AgentIdentity` + reserved-slug additions with a one-line unit test each. +- [ ] **Step 3:** Full gates; commit `Port session and Google auth core from subpixel`. + +### Task 2: Registry Durable Object + +**Files:** +- Create: `agents/registry.ts` +- Modify: `workers/app.ts` (export), `wrangler.jsonc` (binding `Registry`, migration v3 `new_sqlite_classes: ["Registry"]`) +- Test: `tests/integration/agents/registry.test.ts` (mock-Agent pattern; may need its own small sql fake) + +**Interfaces (RPCs, all return values never throws):** +```ts +async upsertProfile(principal: string, info: { displayName: string; avatar?: string }): Promise<{ profile: Profile }> +async getProfile(principal: string): Promise<{ profile: Profile } | { error }> +async ensureAgentSlug(principal: string): Promise<{ slug: string }> // slugify(displayName), global uniquify -2/-3…, stable once set +// OAuth state (namespaced rows): registerClient, getClient, putCode, takeCode (single-use), putRefresh, rotateRefresh, revokeGrant +``` +`Profile = { uid, principal, displayName, avatar: string|null, agentSlug: string|null }`. Follow subpixel `registry.ts` key scheme (`p:`, `u:`, `a:` + `oc:`/`code:`/`rt:` for OAuth). Accessed via `getAgentByName(env.Registry, "global")`. + +- [ ] Failing integration tests: profile upsert/get round-trip; slug uniquification (two principals, displayName "Nicholas J" → `nicholas-j`, `nicholas-j-2`); slug stability across calls; code single-use (second `takeCode` fails); refresh rotate invalidates old. +- [ ] Implement → GREEN → gates → commit `Add global Registry durable object for profiles and OAuth state`. + +### Task 3: Auth HTTP routes + +**Files:** +- Modify: `workers/routes.ts` (add `handleAuth(request, deps): Promise` covering GET /auth/config, POST /auth/google, GET /auth/me, POST /auth/logout), `workers/app.ts` (wire before React Router, after redirects) +- Test: extend `tests/unit/agents/worker-routes.test.ts` + +Deps injected: `{ secret, googleClientId, verifyGoogle, registry: { upsertProfile, getProfile } }`. `POST /auth/google`: same-origin check → verify credential → upsertProfile → mint 30-day session → Set-Cookie + JSON `{ principal, displayName }`. `/auth/me`: session or `{ signedIn: false }`. Logout clears cookie (Max-Age=0). + +- [ ] Failing tests (fake deps): config returns client id; google happy path sets `vp_session` cookie with HttpOnly/SameSite=Lax; cross-origin POST → 403; bad credential → 401; me with/without cookie; logout clears. +- [ ] Implement → GREEN → gates → commit `Add auth routes for Google sign-in sessions`. + +### Task 4: OAuth 2.1 authorization server + +**Files:** +- Create: `workers/oauth.ts` (port of subpixel `server/oauth.ts`), `app/lib/oauth-pages.ts` (consent HTML: client name, GSI sign-in when no session, capability radio — "Suggest & comment" checked / "Full write"; reuse mcp-help.ts styling + origin validation) +- Modify: `workers/app.ts` (route `/oauth/*` + the two `/.well-known/oauth-*` documents) +- Test: `tests/unit/agents/oauth.test.ts` (injected registry/auth fakes) + +Port faithfully: PKCE S256 required; dynamic client registration (`POST /oauth/register`); auth code 10-min TTL single-use; access token = 1h session JWT with `caps` claim; refresh rotation; `POST /oauth/revoke`. Discovery docs advertise issuer `https://vapor.fyi`, endpoints, `code` + `refresh_token` grants, S256. Consent POST requires a valid web session (the GSI flow on the page creates one) and writes the chosen caps into the code record. + +- [ ] Failing tests: register → client id; authorize without session → page contains GSI; full code+PKCE exchange (fixture session) → access token whose claims carry principal + chosen caps; wrong verifier → error; code reuse → error; refresh rotates; revoke kills refresh; discovery JSON shapes. +- [ ] Implement → GREEN → gates → commit `Add OAuth 2.1 authorization server for MCP clients`. + +### Task 5: DocumentAgent speaks identity, tokens die + +**Files:** +- Modify: `agents/document.ts`, `app/shared/agent-protocol.ts` (remove token-only types if any), delete `app/lib/agent-tokens.ts` +- Test: rewrite affected blocks of `tests/integration/agents/document-agent.test.ts`, delete `tests/unit/lib/agent-tokens.test.ts` + +Every `agent*` RPC's first parameter becomes `identity: AgentIdentity` (already verified upstream — DocumentAgent trusts VaporMcp/DO-RPC callers; validate shape defensively, `invalid_token` code renamed usage → keep code for malformed identity). Enrollment: `ensureRosterEntry(identity)` creates/reuses a roster row `{ name, color, owner, capabilities, created_at, last_seen_at }` — name collision for a DIFFERENT identity id gets suffixed (registry-independent, per-doc). Delete: `agent_tokens` table + hashing + mint/verify/revoke-token RPCs (`revokeAgentEntry(name)` replaces revoke, removing roster row + severing presence). Rate limits: keyed `identity.id` in a `rate_limits` roster column. `exportMarkdown`, events, performance engine, presence: unchanged except plumbing. + +- [ ] Rewrite tests first (RED): mutations gated by `identity.caps`; anonymous identity gets DEFAULT_CAPABILITIES enforcement upstream (DocumentAgent honors whatever caps arrive); owner lands in roster; rate limit keyed per identity; alarm purges roster + rate state; thread_reply/mention events keyed by roster name still work. +- [ ] Implement → GREEN → gates → commit `Replace per-doc tokens with verified identity in DocumentAgent`. + +### Task 6: VaporMcp — two doors + +**Files:** +- Modify: `agents/mcp.ts`, `agents/mcp-tools.ts` (ToolDeps carries `identity` not token), `agents/mcp-anonymous.ts` (rename semantics: session-held identity, no tokens), `workers/app.ts` +- Test: `tests/unit/agents/mcp-tools.test.ts`, `tests/unit/agents/mcp-anonymous.test.ts` updates; new `tests/unit/agents/mcp-door.test.ts` for the 401 challenge builder + +Routing in `workers/app.ts`: `/mcp/anonymous` → serve with `props = { auth: { kind: "anonymous" } }`; `/mcp` → verify bearer as session JWT (`verifySessionToken`); valid → `props = { auth: { kind: "principal", claims } }`; missing/invalid → `401` with `WWW-Authenticate: Bearer resource_metadata="https://vapor.fyi/.well-known/oauth-protected-resource"` (exact header per MCP auth spec — verify against the installed SDK's expectations). VaporMcp builds `AgentIdentity`: principal path pulls `agentSlug` via Registry (`ensureAgentSlug`, cached in session state) with `owner = principal`, `caps` from claims; anonymous path keeps clientInfo slug, `owner: null`, DEFAULT_CAPABILITIES. `create_document` returns `{ id, url }` only and enrolls the caller. Help page reachable on both doors' GET-with-Accept-html. + +- [ ] Failing tests → implement → GREEN → gates → commit `Gate /mcp behind OAuth and move tokenless access to /mcp/anonymous`. + +### Task 7: Connection panel replaces the mint dialog + +**Files:** +- Modify: `app/components/InviteAgentDialog.tsx` (rename file/content to `AgentsPanel.tsx` if cleaner — panel shows the two connect commands + roster with revoke), `app/routes/doc.$id.agents.ts` (GET roster + `{ intent: "revoke", name }` only; mint intent removed → 410), header menu label ("Agents") +- Test: update `tests/unit/components/*`, `tests/unit/routes/doc-agents-route.test.ts` + +- [ ] Failing tests → implement → GREEN → gates → commit `Replace token minting UI with agents connection panel`. + +### Task 8: Web sign-in UI + +**Files:** +- Create: `app/components/SignIn.tsx` (header affordance: signed-out → "Sign in" popover loading GSI script with client id from `/auth/config`; signed-in → displayName + sign-out) +- Modify: doc header composition; `app/lib/useYjsEditor.ts` or the awareness-name source (`user.name` = displayName when `/auth/me` says signed in); comment author name likewise +- Test: `tests/unit/components/SignIn.test.tsx` (mock fetch: signed-out renders button, signed-in renders name + sign-out posts logout); a unit test that the awareness name prefers the session displayName + +GSI script loads only when the popover opens (not on every doc view). Anonymous users: zero change. + +- [ ] Failing tests → implement → GREEN → gates → commit `Add Google sign-in to the doc header`. + +### Task 9: Docs, help page, config + +**Files:** +- Modify: `app/lib/mcp-help.ts` (two doors, signed-in first), `README.md`, `CLAUDE.md` (identity architecture note; spec pointer), `wrangler.jsonc` (GOOGLE_CLIENT_ID var placeholder), `.dev.vars.example` +- Test: update mcp-help tests + +- [ ] Update → gates → commit `Document the identity model and two MCP doors`. + +### Task 10: Live acceptance + +- [ ] `SESSION_SECRET`: generate (`openssl rand -base64 32`) → `wrangler secret put` (controller does this at deploy time, not in CI). +- [ ] With the user-supplied `GOOGLE_CLIENT_ID`: `npm run dev`; sign in on web (name appears in presence); OAuth flow end-to-end with curl (register client → authorize w/ session cookie → code+PKCE → token → `/mcp` tool call lands as agentSlug with owner); `/mcp/anonymous` regression; bare `/mcp` returns the 401 challenge shape. +- [ ] Record transcript in the task report. Deploy only on explicit go-ahead. + +## Self-review notes +- Spec coverage: auth core (T1), Registry (T2), auth routes (T3), OAuth AS + consent caps (T4), token retirement + identity RPCs (T5), doors + counterpart enrollment (T6), UI panel (T7), web sign-in + presence attribution (T8), docs/config (T9), acceptance (T10). +- Deliberate scope note: `revokeGrant` in T2 covers `/oauth/revoke`; per-doc severing is T5's `revokeAgentEntry`. Anonymous rate-limit identity id = the MCP session id (stable per session), stated here so T5/T6 agree. +- Ordering: T5 and T6 are coupled (RPC signature change) — execute sequentially, never in parallel. From c3a9450d1e65275f4ab104484a6a698b43f6fad8 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:36:31 -0700 Subject: [PATCH 43/83] Port session and Google auth core from subpixel Dependency-free session JWT (HMAC HS256) and Google ID-token verification (WebCrypto RS256 vs Google JWKS, cached via caches.default), ported from subpixel server/auth.ts. Cookie renamed sp_session -> vp_session; Playdate device-pairing code dropped; JWKS fetch made injectable so tests verify against a fixture RSA keypair instead of the network. Adds AgentIdentity and the auth/oauth/settings reserved slugs to agent-protocol.ts per the identity phase plan's Global Constraints. Co-Authored-By: Claude Fable 5 --- .dev.vars.example | 6 + app/lib/auth.server.ts | 266 +++++++++++++++++++++++ app/shared/agent-protocol.ts | 18 ++ tests/unit/lib/auth-server.test.ts | 242 +++++++++++++++++++++ tests/unit/shared/agent-protocol.test.ts | 21 ++ 5 files changed, 553 insertions(+) create mode 100644 app/lib/auth.server.ts create mode 100644 tests/unit/lib/auth-server.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index f3fcd70b..d1d92a76 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,9 @@ # Fathom analytics (optional — omit to disable) VITE_FATHOM_SITE_ID= VITE_FATHOM_DOMAINS= + +# Identity (optional — omit to keep sign-in disabled). SESSION_SECRET signs +# session JWTs (Workers secret in prod, `openssl rand -base64 32` locally); +# GOOGLE_CLIENT_ID is the public Google OAuth client id for GSI sign-in. +SESSION_SECRET= +GOOGLE_CLIENT_ID= diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts new file mode 100644 index 00000000..867b1622 --- /dev/null +++ b/app/lib/auth.server.ts @@ -0,0 +1,266 @@ +/** + * Session and Google identity verification: dependency-free, WebCrypto-only. + * + * Ported from subpixel server/auth.ts. Adapted for vapor: + * - Session cookie renamed sp_session -> vp_session. + * - Playdate device-pairing code dropped entirely (not part of vapor). + * - `verifyGoogleIdToken` takes an injectable `fetchJwks` so tests can hand + * it a fixture keypair instead of hitting Google's network endpoint. + * - `principalFromEmail` returns a bare string (no null path) per this + * phase's interface contract; callers own email validation upstream. + * + * Importable by both workers/ and React Router server code — must not + * import from agents/ (see docs/plans/2026-08-30-identity-plan.md, Global + * Constraints). + */ +import type { AgentCapability } from "~/shared/agent-protocol"; + +export interface SessionClaims { + principal: string; + email: string; + caps?: AgentCapability[]; + iat: number; + exp: number; +} + +export const SESSION_COOKIE = "vp_session"; + +const GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"; +const DEFAULT_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function stringToBase64Url(value: string): string { + return bytesToBase64Url(encoder.encode(value)); +} + +function base64UrlToBytes(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function decodeJwtPart(value: string): unknown { + return JSON.parse(decoder.decode(base64UrlToBytes(value))); +} + +function jsonPart(value: unknown): string { + return stringToBase64Url(JSON.stringify(value)); +} + +async function hmacKey(secretValue: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secretValue), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +async function signSession(data: string, secretValue: string): Promise { + const key = await hmacKey(secretValue); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); + return bytesToBase64Url(new Uint8Array(signature)); +} + +/** "email:" + lowercased address. Vapor's identity principal. */ +export function principalFromEmail(email: string): string { + return `email:${email.toLowerCase()}`; +} + +export async function mintSessionToken( + claims: Omit, + secret: string, + ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS, +): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: SessionClaims = { ...claims, iat: now, exp: now + ttlSeconds }; + const data = `${jsonPart({ alg: "HS256", typ: "JWT" })}.${jsonPart(payload)}`; + return `${data}.${await signSession(data, secret)}`; +} + +export async function verifySessionToken(token: string, secret: string): Promise { + if (token.length === 0 || token.length > 4096) return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + + let header: unknown; + let payload: unknown; + try { + header = decodeJwtPart(parts[0]); + payload = decodeJwtPart(parts[1]); + } catch { + return null; + } + if (!isRecord(header) || header.alg !== "HS256") return null; + + let signatureBytes: Uint8Array; + try { + signatureBytes = base64UrlToBytes(parts[2]); + } catch { + return null; + } + const data = `${parts[0]}.${parts[1]}`; + const key = await hmacKey(secret); + const ok = await crypto.subtle.verify("HMAC", key, signatureBytes, encoder.encode(data)); + if (!ok) return null; + + if (!isRecord(payload)) return null; + const { principal, email, iat, exp, caps } = payload; + if (typeof principal !== "string" || typeof email !== "string") return null; + if (!Number.isInteger(iat) || !Number.isInteger(exp)) return null; + + const now = Math.floor(Date.now() / 1000); + if ((exp as number) <= now) return null; + if ((iat as number) > now + 60) return null; + if (caps !== undefined && !Array.isArray(caps)) return null; + + return { + principal, + email, + iat: iat as number, + exp: exp as number, + ...(caps !== undefined ? { caps: caps as AgentCapability[] } : {}), + }; +} + +function cookieValue(request: Request, name: string): string | null { + for (const part of (request.headers.get("cookie") ?? "").split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName === name) return rawValue.join("="); + } + return null; +} + +/** One session helper, both doors: browser cookie or Authorization bearer. */ +export async function sessionFromRequest(request: Request, secret: string): Promise { + const token = + cookieValue(request, SESSION_COOKIE) ?? + request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1] ?? + null; + return token ? verifySessionToken(token, secret) : null; +} + +export function sessionCookieHeader(token: string, maxAge: number, secure: boolean): string { + const secureFlag = secure ? "; Secure" : ""; + return `${SESSION_COOKIE}=${token}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax${secureFlag}`; +} + +export function clearSessionCookieHeader(secure: boolean): string { + return sessionCookieHeader("", 0, secure); +} + +/** Same-origin guard on credential-posting endpoints (/auth/google, consent). */ +export function sameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +type GoogleJwk = JsonWebKey & { kid?: string }; +type FetchJwks = (url: string) => Promise; + +async function defaultFetchJwks(url: string): Promise { + const cache = typeof caches !== "undefined" ? (caches as CacheStorage & { default: Cache }).default : undefined; + const request = new Request(url); + const cached = await cache?.match(request); + if (cached) { + const data = (await cached.json()) as { keys?: GoogleJwk[] }; + return data.keys ?? []; + } + const response = await fetch(request); + if (!response.ok) throw new Error("google jwks fetch failed"); + await cache?.put(request, response.clone()); + const data = (await response.json()) as { keys?: GoogleJwk[] }; + return data.keys ?? []; +} + +async function verifyRs256(data: string, signature: Uint8Array, jwk: GoogleJwk): Promise { + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, encoder.encode(data)); +} + +/** + * Verifies a Google GSI ID token: RS256 signature against Google's JWKS + * (cached via `caches.default` when available), issuer/audience/expiry, and + * `email_verified`. `fetchJwks` defaults to a live fetch of Google's cert + * URL; tests inject a fixture that returns a locally-generated keypair. + */ +export async function verifyGoogleIdToken( + credential: string, + clientId: string, + fetchJwks: FetchJwks = defaultFetchJwks, +): Promise<{ email: string; name: string; picture?: string } | null> { + if (credential.length === 0 || credential.length > 8192) return null; + const parts = credential.split("."); + if (parts.length !== 3) return null; + + let header: unknown; + let payload: unknown; + try { + header = decodeJwtPart(parts[0]); + payload = decodeJwtPart(parts[1]); + } catch { + return null; + } + if (!isRecord(header) || header.alg !== "RS256" || typeof header.kid !== "string") return null; + + let keys: GoogleJwk[]; + try { + keys = await fetchJwks(GOOGLE_JWKS_URL); + } catch { + return null; + } + const key = keys.find((candidate) => candidate.kid === header.kid); + if (!key) return null; + + let signatureBytes: Uint8Array; + try { + signatureBytes = base64UrlToBytes(parts[2]); + } catch { + return null; + } + const verified = await verifyRs256(`${parts[0]}.${parts[1]}`, signatureBytes, key); + if (!verified) return null; + + if (!isRecord(payload)) return null; + const { iss, aud, exp, email, email_verified: emailVerified, name, picture } = payload; + if (iss !== "accounts.google.com" && iss !== "https://accounts.google.com") return null; + if (aud !== clientId) return null; + if (!Number.isInteger(exp)) return null; + + const now = Math.floor(Date.now() / 1000); + if ((exp as number) <= now) return null; + if (emailVerified !== true || typeof email !== "string") return null; + + const normalizedEmail = email.toLowerCase(); + return { + email: normalizedEmail, + name: typeof name === "string" && name.length > 0 ? name : normalizedEmail, + ...(typeof picture === "string" ? { picture } : {}), + }; +} diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index 78e4fea3..105dc30c 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -19,6 +19,21 @@ export interface DocBlock extends BlockAnchor { text: string; // markdown w/ critic delimiters } +/** + * A caller's verified identity, as established upstream (session cookie or + * OAuth bearer token) and passed down to DocumentAgent/VaporMcp — the single + * source of truth for both MCP doors. `kind: "anonymous"` covers tokenless + * `/mcp/anonymous` callers (DEFAULT_CAPABILITIES, no owner); `kind: + * "principal"` covers signed-in/OAuth callers (caps from the OAuth grant). + */ +export interface AgentIdentity { + kind: "principal" | "anonymous"; + id: string; // principal ("email:…") or anonymous session key + name: string; // roster/display slug (agentSlug or slugified clientInfo) + owner: string | null; // principal for kind=principal, null for anonymous + caps: AgentCapability[]; +} + export interface AgentError { code: AgentErrorCode; message: string; @@ -57,6 +72,9 @@ export const RESERVED_SLUGS = [ "favicon.ico", "robots.txt", ".well-known", + "auth", + "oauth", + "settings", ]; /** Whether a root slug is reserved (case-insensitive — URLs aren't). */ diff --git a/tests/unit/lib/auth-server.test.ts b/tests/unit/lib/auth-server.test.ts new file mode 100644 index 00000000..fa41b338 --- /dev/null +++ b/tests/unit/lib/auth-server.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from "vitest"; +import { + mintSessionToken, + verifySessionToken, + verifyGoogleIdToken, + sessionFromRequest, + sessionCookieHeader, + principalFromEmail, + SESSION_COOKIE, +} from "~/lib/auth.server"; + +const SECRET = "a".repeat(32); + +// ---- base64url + fixture-JWT helpers (deliberately independent of the +// module under test, so tests exercise the public contract only) ---- + +function base64UrlFromBytes(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlFromJson(value: unknown): string { + return base64UrlFromBytes(new TextEncoder().encode(JSON.stringify(value))); +} + +type FixtureKeys = { privateKey: CryptoKey; jwk: JsonWebKey & { kid: string } }; + +async function generateFixtureKeys(kid = "test-kid"): Promise { + const { privateKey, publicKey } = await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + ); + const jwk = (await crypto.subtle.exportKey("jwk", publicKey)) as JsonWebKey & { kid: string }; + jwk.kid = kid; + return { privateKey, jwk }; +} + +async function signIdToken( + privateKey: CryptoKey, + kid: string, + payload: Record, +): Promise { + const header = base64UrlFromJson({ alg: "RS256", kid, typ: "JWT" }); + const body = base64UrlFromJson(payload); + const signingInput = `${header}.${body}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${base64UrlFromBytes(new Uint8Array(signature))}`; +} + +const CLIENT_ID = "test-client-id.apps.googleusercontent.com"; +const now = () => Math.floor(Date.now() / 1000); + +function validGooglePayload(overrides: Record = {}) { + return { + iss: "https://accounts.google.com", + aud: CLIENT_ID, + exp: now() + 3600, + email: "Foo@Bar.com", + email_verified: true, + name: "Foo Bar", + picture: "https://example.com/pic.png", + ...overrides, + }; +} + +describe("principalFromEmail", () => { + it("lowercases and prefixes", () => { + expect(principalFromEmail("Foo@Bar.COM")).toBe("email:foo@bar.com"); + }); +}); + +describe("session mint/verify round trip", () => { + it("verifies a token it minted", async () => { + const token = await mintSessionToken( + { principal: "email:foo@bar.com", email: "foo@bar.com" }, + SECRET, + ); + const claims = await verifySessionToken(token, SECRET); + expect(claims).not.toBeNull(); + expect(claims?.principal).toBe("email:foo@bar.com"); + expect(claims?.email).toBe("foo@bar.com"); + expect(typeof claims?.iat).toBe("number"); + expect(typeof claims?.exp).toBe("number"); + }); + + it("carries optional caps through", async () => { + const token = await mintSessionToken( + { principal: "email:foo@bar.com", email: "foo@bar.com", caps: ["comment", "suggest"] }, + SECRET, + ); + const claims = await verifySessionToken(token, SECRET); + expect(claims?.caps).toEqual(["comment", "suggest"]); + }); + + it("rejects a token minted with a different secret", async () => { + const token = await mintSessionToken( + { principal: "email:foo@bar.com", email: "foo@bar.com" }, + SECRET, + ); + const claims = await verifySessionToken(token, "b".repeat(32)); + expect(claims).toBeNull(); + }); + + it("rejects an expired token", async () => { + const token = await mintSessionToken( + { principal: "email:foo@bar.com", email: "foo@bar.com" }, + SECRET, + -10, + ); + const claims = await verifySessionToken(token, SECRET); + expect(claims).toBeNull(); + }); + + it("rejects a tampered payload", async () => { + const token = await mintSessionToken( + { principal: "email:foo@bar.com", email: "foo@bar.com" }, + SECRET, + ); + const [header, , signature] = token.split("."); + const tamperedPayload = base64UrlFromJson({ + principal: "email:attacker@bar.com", + email: "attacker@bar.com", + iat: now(), + exp: now() + 1000, + }); + const tampered = `${header}.${tamperedPayload}.${signature}`; + expect(await verifySessionToken(tampered, SECRET)).toBeNull(); + // sanity: the untampered token still round-trips + expect(await verifySessionToken(token, SECRET)).not.toBeNull(); + }); + + it("rejects garbage tokens", async () => { + expect(await verifySessionToken("not.a.jwt", SECRET)).toBeNull(); + expect(await verifySessionToken("", SECRET)).toBeNull(); + }); +}); + +describe("verifyGoogleIdToken", () => { + it("accepts a fixture-signed token with correct aud/iss/exp", async () => { + const { privateKey, jwk } = await generateFixtureKeys(); + const token = await signIdToken(privateKey, jwk.kid, validGooglePayload()); + const fetchJwks = async () => [jwk]; + const result = await verifyGoogleIdToken(token, CLIENT_ID, fetchJwks); + expect(result).toEqual({ + email: "foo@bar.com", + name: "Foo Bar", + picture: "https://example.com/pic.png", + }); + }); + + it("rejects the wrong audience", async () => { + const { privateKey, jwk } = await generateFixtureKeys(); + const token = await signIdToken(privateKey, jwk.kid, validGooglePayload({ aud: "someone-else" })); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); + + it("rejects the wrong issuer", async () => { + const { privateKey, jwk } = await generateFixtureKeys(); + const token = await signIdToken(privateKey, jwk.kid, validGooglePayload({ iss: "evil.example.com" })); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); + + it("rejects an expired token", async () => { + const { privateKey, jwk } = await generateFixtureKeys(); + const token = await signIdToken(privateKey, jwk.kid, validGooglePayload({ exp: now() - 10 })); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); + + it("rejects a bad signature (signed by an unrelated key)", async () => { + const { jwk } = await generateFixtureKeys(); + const attacker = await generateFixtureKeys(jwk.kid); + // Token is signed by the attacker's private key but claims the + // legitimate kid; the JWKS fixture only knows the legitimate public key. + const token = await signIdToken(attacker.privateKey, jwk.kid, validGooglePayload()); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); + + it("rejects an unverified email", async () => { + const { privateKey, jwk } = await generateFixtureKeys(); + const token = await signIdToken(privateKey, jwk.kid, validGooglePayload({ email_verified: false })); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); + + it("rejects an unknown kid", async () => { + const { privateKey, jwk } = await generateFixtureKeys("kid-a"); + const token = await signIdToken(privateKey, "kid-b", validGooglePayload()); + const result = await verifyGoogleIdToken(token, CLIENT_ID, async () => [jwk]); + expect(result).toBeNull(); + }); +}); + +describe("sessionCookieHeader", () => { + it("produces the expected cookie shape", () => { + const header = sessionCookieHeader("tok123", 86400, false); + expect(header).toBe(`${SESSION_COOKIE}=tok123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax`); + }); + + it("adds Secure when requested", () => { + const header = sessionCookieHeader("tok123", 86400, true); + expect(header).toContain("; Secure"); + }); + + it("uses the vp_session cookie name", () => { + expect(SESSION_COOKIE).toBe("vp_session"); + }); +}); + +describe("sessionFromRequest", () => { + it("reads the session from the vp_session cookie", async () => { + const token = await mintSessionToken({ principal: "email:foo@bar.com", email: "foo@bar.com" }, SECRET); + const request = new Request("https://vapor.fyi/", { + headers: { cookie: `${SESSION_COOKIE}=${token}` }, + }); + const claims = await sessionFromRequest(request, SECRET); + expect(claims?.principal).toBe("email:foo@bar.com"); + }); + + it("falls back to an Authorization: Bearer header", async () => { + const token = await mintSessionToken({ principal: "email:foo@bar.com", email: "foo@bar.com" }, SECRET); + const request = new Request("https://vapor.fyi/", { + headers: { authorization: `Bearer ${token}` }, + }); + const claims = await sessionFromRequest(request, SECRET); + expect(claims?.principal).toBe("email:foo@bar.com"); + }); + + it("returns null with no credential", async () => { + const request = new Request("https://vapor.fyi/"); + expect(await sessionFromRequest(request, SECRET)).toBeNull(); + }); +}); diff --git a/tests/unit/shared/agent-protocol.test.ts b/tests/unit/shared/agent-protocol.test.ts index ac30ed2c..bce9e4e9 100644 --- a/tests/unit/shared/agent-protocol.test.ts +++ b/tests/unit/shared/agent-protocol.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { blockHash, formatAnchor, parseAnchor, findMentions, AGENT_NAME_RE, RESERVED_SLUGS, isReservedSlug, slugifyAgentName, + type AgentIdentity, } from "~/shared/agent-protocol"; describe("blockHash", () => { @@ -47,6 +48,7 @@ describe("reserved slugs", () => { expect.arrayContaining([ "new", "mcp", "agents", "api", "assets", "demo", "favicon.ico", "robots.txt", ".well-known", + "auth", "oauth", "settings", ]), ); }); @@ -57,12 +59,31 @@ describe("reserved slugs", () => { expect(isReservedSlug("Robots.txt")).toBe(true); }); + it("reserves the identity-phase routes (auth, oauth, settings)", () => { + expect(isReservedSlug("auth")).toBe(true); + expect(isReservedSlug("oauth")).toBe(true); + expect(isReservedSlug("settings")).toBe(true); + }); + it("does not match ordinary document ids", () => { expect(isReservedSlug("abcd1234")).toBe(false); expect(isReservedSlug("newx1234")).toBe(false); }); }); +describe("AgentIdentity", () => { + it("accepts the verified-identity shape from both doors", () => { + const identity: AgentIdentity = { + kind: "principal", + id: "email:foo@bar.com", + name: "foo-bar", + owner: "email:foo@bar.com", + caps: ["comment", "suggest"], + }; + expect(identity.kind).toBe("principal"); + }); +}); + describe("slugifyAgentName", () => { it("lowercases and passes through an already-valid slug", () => { expect(slugifyAgentName("Claude Code")).toBe("claude-code"); From 6806a90f0b3dd48251c6f629c5ffdda8366ac0b3 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:44:26 -0700 Subject: [PATCH 44/83] Add anonymous animal identities with Noto Emoji presence Each browser gets a persistent anonymous identity (uuid, animal, cursor colour) in localStorage. Presence and comments show the animal glyph in monochrome Noto Emoji, tinted with the user's colour. retireAnonId() prepares the sign-in re-attribution flow. Co-Authored-By: Claude Fable 5 --- app/app.css | 12 +++ app/components/Editor.tsx | 6 ++ app/components/ThreadPanel.tsx | 10 +++ app/lib/anon-identity.ts | 93 ++++++++++++++++++++++++ app/lib/useYjsEditor.ts | 13 ++-- app/root.tsx | 6 ++ app/shared/anon-animals.ts | 58 +++++++++++++++ app/shared/types.ts | 4 + docs/plans/2026-08-30-identity-design.md | 10 +++ tests/unit/lib/anon-identity.test.ts | 41 +++++++++++ 10 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 app/lib/anon-identity.ts create mode 100644 app/shared/anon-animals.ts create mode 100644 tests/unit/lib/anon-identity.test.ts diff --git a/app/app.css b/app/app.css index ac4f0a3f..96064004 100644 --- a/app/app.css +++ b/app/app.css @@ -69,6 +69,18 @@ body { pointer-events: none; } +/* Monochrome animal glyphs (Noto Emoji) — tinted via `color`. */ +.anon-animal { + font-family: "Noto Emoji", var(--font-sans); + font-weight: 600; + line-height: 1; +} + +.tiptap .collaboration-cursor__animal { + margin-right: 0.3em; + font-size: 0.9em; +} + .tiptap .collaboration-cursor__badge { margin-left: 0.3em; padding: 0 0.25em; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index f7c8347e..ce3b00c9 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -180,6 +180,12 @@ function renderCaret(user: Record) { const label = document.createElement("div"); label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); + if (user.animal) { + const animal = document.createElement("span"); + animal.classList.add("anon-animal", "collaboration-cursor__animal"); + animal.insertBefore(document.createTextNode(user.animal as string), null); + label.insertBefore(animal, null); + } label.insertBefore(document.createTextNode(user.name as string), null); if (user.isAgent) { diff --git a/app/components/ThreadPanel.tsx b/app/components/ThreadPanel.tsx index 54e9ffbb..b7f0259e 100644 --- a/app/components/ThreadPanel.tsx +++ b/app/components/ThreadPanel.tsx @@ -70,6 +70,11 @@ export default function ThreadPanel({ > {/* Author + timestamp */}
+ {thread.author.animal && ( + + {thread.author.animal} + + )} {thread.author.name} {timeAgo(thread.createdAt)}
@@ -90,6 +95,11 @@ export default function ThreadPanel({ {thread.replies.map((reply) => (
+ {reply.author.animal && ( + + {reply.author.animal} + + )} {reply.author.name} {timeAgo(reply.createdAt)} diff --git a/app/lib/anon-identity.ts b/app/lib/anon-identity.ts new file mode 100644 index 00000000..37e606f4 --- /dev/null +++ b/app/lib/anon-identity.ts @@ -0,0 +1,93 @@ +import { ANON_ANIMALS } from "~/shared/anon-animals"; +import { USER_COLOURS } from "~/shared/constants"; +import type { AnonAnimal } from "~/shared/anon-animals"; + +const STORAGE_KEY = "vapor-anon"; +const FORMER_KEY = "vapor-former-anon-id"; + +export interface AnonIdentity { + id: string; + animal: AnonAnimal; + colorIndex: number; +} + +interface StoredAnon { + id: string; + animalIndex: number; + colorIndex: number; +} + +function randomIndex(bound: number): number { + return Math.floor(Math.random() * bound); +} + +function toIdentity(stored: StoredAnon): AnonIdentity { + return { + id: stored.id, + animal: ANON_ANIMALS[stored.animalIndex % ANON_ANIMALS.length], + colorIndex: stored.colorIndex % USER_COLOURS.length, + }; +} + +/** + * The browser's persistent anonymous identity: a stable random id, an + * animal, and a cursor colour, assigned once and reused across documents + * and sessions. Falls back to an ephemeral identity when localStorage is + * unavailable (private windows, SSR-adjacent environments). + */ +export function getAnonIdentity(): AnonIdentity { + const fresh: StoredAnon = { + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `anon-${Date.now()}-${randomIndex(1_000_000)}`, + animalIndex: randomIndex(ANON_ANIMALS.length), + colorIndex: randomIndex(USER_COLOURS.length), + }; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.id === "string" && + typeof parsed.animalIndex === "number" && + typeof parsed.colorIndex === "number" + ) { + return toIdentity(parsed as StoredAnon); + } + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); + } catch { + // Storage unavailable — ephemeral identity for this page view. + } + return toIdentity(fresh); +} + +/** + * Called after sign-in: retires the anonymous id so future doc visits can + * re-attribute this browser's earlier anonymous work to the signed-in + * principal. Returns the retired id, or null if there was none. + */ +export function retireAnonId(): string | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.id !== "string") return null; + localStorage.setItem(FORMER_KEY, parsed.id); + localStorage.removeItem(STORAGE_KEY); + return parsed.id; + } catch { + return null; + } +} + +/** The previously retired anonymous id, for re-attribution on doc visits. */ +export function formerAnonId(): string | null { + try { + return localStorage.getItem(FORMER_KEY); + } catch { + return null; + } +} diff --git a/app/lib/useYjsEditor.ts b/app/lib/useYjsEditor.ts index e0265ffc..202070a3 100644 --- a/app/lib/useYjsEditor.ts +++ b/app/lib/useYjsEditor.ts @@ -4,22 +4,25 @@ import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; import { YjsProvider } from "./yjs-provider"; import { USER_COLOURS } from "~/shared/constants"; +import { getAnonIdentity } from "./anon-identity"; import type { UserInfo, DocMode } from "~/shared/types"; -function randomUserInfo(): UserInfo { - const idx = Math.floor(Math.random() * USER_COLOURS.length); - const c = USER_COLOURS[idx]; +function anonUserInfo(): UserInfo { + const anon = getAnonIdentity(); + const c = USER_COLOURS[anon.colorIndex]; return { - name: `User ${Math.floor(Math.random() * 1000)}`, + name: `Anonymous ${anon.animal.name}`, color: c.color, colorLight: c.light, + animal: anon.animal.glyph, + id: anon.id, }; } export function useYjsEditor(docId: string) { const doc = useMemo(() => new Y.Doc(), []); const awareness = useMemo(() => new Awareness(doc), [doc]); - const user = useMemo(() => randomUserInfo(), []); + const user = useMemo(() => anonUserInfo(), []); const docState = useMemo(() => doc.getMap("docState"), [doc]); const providerRef = useRef(null); const [synced, setSynced] = useState(false); diff --git a/app/root.tsx b/app/root.tsx index 340210c9..e6417d4c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -23,6 +23,12 @@ export const links: Route.LinksFunction = () => [ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap", }, + { + // Monochrome emoji for anonymous-animal presence — glyphs inherit + // `color`, so animals can wear the user's cursor colour. + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@400;600&display=swap", + }, ]; const themeScript = `(function(){var t=localStorage.getItem('vapor-theme')||'auto';document.documentElement.setAttribute('data-theme',t)})()`; diff --git a/app/shared/anon-animals.ts b/app/shared/anon-animals.ts new file mode 100644 index 00000000..c530cf4f --- /dev/null +++ b/app/shared/anon-animals.ts @@ -0,0 +1,58 @@ +/** + * Anonymous presence animals, Google-Docs style. + * + * Glyphs are rendered in the monochrome Noto Emoji font (loaded in + * root.tsx) so they inherit `color` and can be tinted with the user's + * cursor colour. Every glyph below has coverage in Noto Emoji. + */ +export interface AnonAnimal { + glyph: string; + name: string; +} + +export const ANON_ANIMALS: readonly AnonAnimal[] = [ + { glyph: "🐙", name: "Octopus" }, + { glyph: "🦊", name: "Fox" }, + { glyph: "🦝", name: "Raccoon" }, + { glyph: "🐢", name: "Turtle" }, + { glyph: "🦉", name: "Owl" }, + { glyph: "🐸", name: "Frog" }, + { glyph: "🦆", name: "Duck" }, + { glyph: "🦡", name: "Badger" }, + { glyph: "🦦", name: "Otter" }, + { glyph: "🐨", name: "Koala" }, + { glyph: "🐼", name: "Panda" }, + { glyph: "🦔", name: "Hedgehog" }, + { glyph: "🐰", name: "Rabbit" }, + { glyph: "🐿️", name: "Chipmunk" }, + { glyph: "🦇", name: "Bat" }, + { glyph: "🐺", name: "Wolf" }, + { glyph: "🦁", name: "Lion" }, + { glyph: "🐯", name: "Tiger" }, + { glyph: "🐮", name: "Cow" }, + { glyph: "🐷", name: "Pig" }, + { glyph: "🐭", name: "Mouse" }, + { glyph: "🐹", name: "Hamster" }, + { glyph: "🐻", name: "Bear" }, + { glyph: "🐧", name: "Penguin" }, + { glyph: "🐤", name: "Chick" }, + { glyph: "🦅", name: "Eagle" }, + { glyph: "🦜", name: "Parrot" }, + { glyph: "🦢", name: "Swan" }, + { glyph: "🦩", name: "Flamingo" }, + { glyph: "🦚", name: "Peacock" }, + { glyph: "🐬", name: "Dolphin" }, + { glyph: "🐳", name: "Whale" }, + { glyph: "🐠", name: "Fish" }, + { glyph: "🦈", name: "Shark" }, + { glyph: "🦭", name: "Seal" }, + { glyph: "🐊", name: "Crocodile" }, + { glyph: "🦎", name: "Lizard" }, + { glyph: "🐍", name: "Snake" }, + { glyph: "🦋", name: "Butterfly" }, + { glyph: "🐝", name: "Bee" }, + { glyph: "🐞", name: "Ladybug" }, + { glyph: "🦀", name: "Crab" }, + { glyph: "🦞", name: "Lobster" }, + { glyph: "🐌", name: "Snail" }, +] as const; diff --git a/app/shared/types.ts b/app/shared/types.ts index aa87fe20..7f285146 100644 --- a/app/shared/types.ts +++ b/app/shared/types.ts @@ -2,6 +2,10 @@ export interface UserInfo { name: string; color: string; colorLight: string; + /** Monochrome animal glyph for anonymous users (rendered in Noto Emoji, tinted with `color`). */ + animal?: string; + /** Stable identity key: the browser's anonymous uuid, or a principal after sign-in. */ + id?: string; } export type DocMode = "edit" | "suggest"; diff --git a/docs/plans/2026-08-30-identity-design.md b/docs/plans/2026-08-30-identity-design.md index 06b3ad38..e4c47767 100644 --- a/docs/plans/2026-08-30-identity-design.md +++ b/docs/plans/2026-08-30-identity-design.md @@ -78,6 +78,16 @@ One standing agent identity per user: - The roster UI shows the owner; the caret badge is unchanged. Revoke in a doc severs that doc's entry only; the OAuth grant itself is revoked via `/oauth/revoke` or a future settings page. - Invariant: counterpart capabilities ≤ the grant's caps ≤ what any URL-holder could do anyway (all docs world-editable this phase), preserving the no-escalation argument. +## Anonymous animals (added same day) + +Google-Docs-style anonymous identities, vapor-flavored: + +- Each browser gets a persistent anonymous identity in localStorage (`vapor-anon`): `{ id: , animal: , colorIndex }`, assigned on first visit and stable across docs and sessions. +- The display name is "Anonymous " and the glyph renders in the **Noto Emoji** font (the monochrome one, loaded from Google Fonts) so it can be tinted with `currentColor` — the animal literally wears the user's cursor color, in the presence stack and the caret label. +- Awareness `user` state gains `animal` and `id` (the anon uuid — random, no fingerprinting value); comment authors gain `id` too. +- **Sign-in rewrites the identity**: on sign-in the client switches awareness to the real displayName (principal as `id`), and for any doc it has open, re-attributes its own past comments — threads/replies whose `author.id` equals the stored anon id get rewritten to the signed-in name and principal. The anon id is then retired (kept in localStorage as `formerAnonId` for later doc visits to repeat the rewrite). +- No server-side registry of anon ids — the rewrite is client-driven, per doc, on visit. Best-effort by design. + ## Web sign-in - A **Sign in** affordance in the doc header (GSI button in a small popover; subpixel's `web/js/auth.js` is the reference). Optional forever. diff --git a/tests/unit/lib/anon-identity.test.ts b/tests/unit/lib/anon-identity.test.ts new file mode 100644 index 00000000..b04dc178 --- /dev/null +++ b/tests/unit/lib/anon-identity.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from "vitest"; +import { getAnonIdentity, retireAnonId, formerAnonId } from "~/lib/anon-identity"; +import { ANON_ANIMALS } from "~/shared/anon-animals"; +import { USER_COLOURS } from "~/shared/constants"; + +describe("anon identity", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("creates and persists a stable identity", () => { + const first = getAnonIdentity(); + const second = getAnonIdentity(); + expect(second.id).toBe(first.id); + expect(second.animal.glyph).toBe(first.animal.glyph); + expect(second.colorIndex).toBe(first.colorIndex); + expect(ANON_ANIMALS.map((a) => a.glyph)).toContain(first.animal.glyph); + expect(first.colorIndex).toBeGreaterThanOrEqual(0); + expect(first.colorIndex).toBeLessThan(USER_COLOURS.length); + }); + + it("survives corrupt storage by regenerating", () => { + localStorage.setItem("vapor-anon", "{not json"); + const identity = getAnonIdentity(); + expect(identity.id).toBeTruthy(); + }); + + it("retire moves the id to formerAnonId and clears the identity", () => { + const identity = getAnonIdentity(); + const retired = retireAnonId(); + expect(retired).toBe(identity.id); + expect(formerAnonId()).toBe(identity.id); + const next = getAnonIdentity(); + expect(next.id).not.toBe(identity.id); + }); + + it("retire with no identity returns null", () => { + expect(retireAnonId()).toBeNull(); + }); +}); From 9aebefdb16d436907efd194c517415417173c5ad Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:48:31 -0700 Subject: [PATCH 45/83] Add global Registry durable object for profiles and OAuth state Co-Authored-By: Claude Fable 5 --- agents/registry.ts | 213 ++++++++++++++++++++++ tests/integration/agents/registry.test.ts | 126 +++++++++++++ workers/app.ts | 1 + wrangler.jsonc | 6 +- 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 agents/registry.ts create mode 100644 tests/integration/agents/registry.test.ts diff --git a/agents/registry.ts b/agents/registry.ts new file mode 100644 index 00000000..5e36ea0d --- /dev/null +++ b/agents/registry.ts @@ -0,0 +1,213 @@ +import { Agent } from "agents"; +import { slugifyAgentName } from "../app/shared/agent-protocol"; +import type { AgentCapability } from "../app/shared/agent-protocol"; + +// Global identity registry, one instance ("global") per deployment. +// Modeled on subpixel's server/registry.ts, adapted to the Agents SDK and +// vapor's kv-on-sql test conventions. Key namespaces: +// p: -> Profile +// u: -> principal +// a: -> principal +// oc: -> OAuthClient +// code: -> AuthCode (single-use, 10 min TTL) +// rt: -> RefreshGrant (rotated on use) + +export interface Profile { + principal: string; + uid: string; + displayName: string; + avatar: string | null; + agentSlug: string | null; +} + +export interface OAuthClient { + clientId: string; + name: string; + redirectUris: string[]; + createdAt: number; +} + +export interface AuthCode { + clientId: string; + principal: string; + caps: AgentCapability[]; + codeChallenge: string; + redirectUri: string; + exp: number; +} + +export interface RefreshGrant { + clientId: string; + principal: string; + caps: AgentCapability[]; + exp: number; +} + +const CODE_TTL_MS = 10 * 60 * 1000; +const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +function randomToken(prefix: string): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let b64 = ""; + for (const b of bytes) b64 += String.fromCharCode(b); + return prefix + btoa(b64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +class Registry extends Agent { + private initialised = false; + + private ensureTable(): void { + if (this.initialised) return; + this.sql` + CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT + ) + `; + this.initialised = true; + } + + private kvGet(key: string): T | null { + this.ensureTable(); + const rows = this.sql<{ value: string }>` + SELECT value FROM kv WHERE key = ${key} + `; + if (rows.length === 0) return null; + try { + return JSON.parse(rows[0].value) as T; + } catch { + return null; + } + } + + private kvPut(key: string, value: unknown): void { + this.ensureTable(); + const encoded = JSON.stringify(value); + this.sql` + INSERT INTO kv (key, value) VALUES (${key}, ${encoded}) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `; + } + + private kvDelete(key: string): void { + this.ensureTable(); + this.sql`DELETE FROM kv WHERE key = ${key}`; + } + + /* ---------------- profiles ---------------- */ + + async upsertProfile( + principal: string, + info: { displayName: string; avatar?: string }, + ): Promise<{ profile: Profile }> { + const existing = this.kvGet(`p:${principal}`); + const profile: Profile = existing + ? { ...existing, displayName: info.displayName, avatar: info.avatar ?? existing.avatar } + : { + principal, + uid: crypto.randomUUID(), + displayName: info.displayName, + avatar: info.avatar ?? null, + agentSlug: null, + }; + this.kvPut(`p:${principal}`, profile); + if (!existing) { + this.kvPut(`u:${profile.uid}`, principal); + } + return { profile }; + } + + async getProfile(principal: string): Promise<{ profile: Profile | null }> { + return { profile: this.kvGet(`p:${principal}`) }; + } + + /** + * The user's stable counterpart-agent slug: derived from the display + * name on first request, globally unique, then never changed here. + */ + async ensureAgentSlug( + principal: string, + ): Promise<{ slug: string } | { error: { code: string; message: string } }> { + const profile = this.kvGet(`p:${principal}`); + if (!profile) { + return { error: { code: "not_found", message: "No profile for principal" } }; + } + if (profile.agentSlug) return { slug: profile.agentSlug }; + + const base = slugifyAgentName(profile.displayName); + let candidate = base; + for (let n = 2; this.kvGet(`a:${candidate}`) !== null; n++) { + candidate = `${base}-${n}`; + } + profile.agentSlug = candidate; + this.kvPut(`p:${principal}`, profile); + this.kvPut(`a:${candidate}`, principal); + return { slug: candidate }; + } + + /* ---------------- oauth state ---------------- */ + + async registerClient(info: { + name: string; + redirectUris: string[]; + }): Promise<{ client: OAuthClient }> { + const client: OAuthClient = { + clientId: crypto.randomUUID(), + name: info.name, + redirectUris: info.redirectUris, + createdAt: Date.now(), + }; + this.kvPut(`oc:${client.clientId}`, client); + return { client }; + } + + async getClient(clientId: string): Promise<{ client: OAuthClient | null }> { + return { client: this.kvGet(`oc:${clientId}`) }; + } + + async putCode( + data: Omit, + ): Promise<{ code: string }> { + const code = randomToken("vac_"); + this.kvPut(`code:${code}`, { ...data, exp: Date.now() + CODE_TTL_MS } satisfies AuthCode); + return { code }; + } + + /** Single use: the code is deleted whether or not it is still valid. */ + async takeCode(code: string): Promise<{ data: AuthCode | null }> { + const data = this.kvGet(`code:${code}`); + this.kvDelete(`code:${code}`); + if (!data || data.exp < Date.now()) return { data: null }; + return { data }; + } + + async putRefresh( + data: Omit, + ): Promise<{ token: string }> { + const token = randomToken("var_"); + this.kvPut(`rt:${token}`, { ...data, exp: Date.now() + REFRESH_TTL_MS } satisfies RefreshGrant); + return { token }; + } + + /** Rotation: the old token is consumed; a fresh one is issued for the same grant. */ + async rotateRefresh( + oldToken: string, + ): Promise<{ token: string; data: RefreshGrant } | { error: { code: string; message: string } }> { + const data = this.kvGet(`rt:${oldToken}`); + this.kvDelete(`rt:${oldToken}`); + if (!data || data.exp < Date.now()) { + return { error: { code: "invalid_grant", message: "Refresh token is unknown or expired" } }; + } + const token = randomToken("var_"); + this.kvPut(`rt:${token}`, { ...data, exp: Date.now() + REFRESH_TTL_MS } satisfies RefreshGrant); + return { token, data }; + } + + async revokeRefresh(token: string): Promise<{ ok: true }> { + this.kvDelete(`rt:${token}`); + return { ok: true }; + } +} + +export default Registry; diff --git a/tests/integration/agents/registry.test.ts b/tests/integration/agents/registry.test.ts new file mode 100644 index 00000000..65534c70 --- /dev/null +++ b/tests/integration/agents/registry.test.ts @@ -0,0 +1,126 @@ +/** + * Registry integration tests: real Registry code over a mocked Agent base + * with an in-memory kv table fake (same philosophy as document-agent.test.ts). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +let kvStore: Map; + +vi.mock("agents", () => ({ + Agent: class MockAgent { + name = "global"; + env = {}; + ctx = { storage: {} }; + + sql(strings: TemplateStringsArray, ...values: unknown[]) { + const query = strings.join("?").toLowerCase().replace(/\s+/g, " ").trim(); + if (query.includes("create table")) return []; + if (query.startsWith("insert into kv")) { + kvStore.set(String(values[0]), String(values[1])); + return []; + } + if (query.startsWith("select value from kv")) { + const value = kvStore.get(String(values[0])); + return value === undefined ? [] : [{ value }]; + } + if (query.startsWith("delete from kv")) { + kvStore.delete(String(values[0])); + return []; + } + throw new Error(`kv mock: unhandled query: ${query}`); + } + }, +})); + +import Registry from "../../../agents/registry"; + +function makeRegistry() { + kvStore = new Map(); + return new Registry({} as never, {} as never); +} + +describe("Registry", () => { + beforeEach(() => { + kvStore = new Map(); + }); + + it("upserts and reads a profile, preserving uid and slug on update", async () => { + const reg = makeRegistry(); + const { profile } = await reg.upsertProfile("email:nicholas@artifact.com", { + displayName: "Nicholas J", + }); + expect(profile.uid).toBeTruthy(); + expect(profile.agentSlug).toBeNull(); + + const slug = await reg.ensureAgentSlug("email:nicholas@artifact.com"); + expect(slug).toEqual({ slug: "nicholas-j" }); + + const updated = await reg.upsertProfile("email:nicholas@artifact.com", { + displayName: "Nicholas", + avatar: "https://example.com/a.png", + }); + expect(updated.profile.uid).toBe(profile.uid); + expect(updated.profile.agentSlug).toBe("nicholas-j"); + expect(updated.profile.avatar).toBe("https://example.com/a.png"); + }); + + it("uniquifies agent slugs globally and keeps them stable", async () => { + const reg = makeRegistry(); + await reg.upsertProfile("email:a@x.com", { displayName: "Nicholas J" }); + await reg.upsertProfile("email:b@x.com", { displayName: "Nicholas J" }); + expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "nicholas-j" }); + expect(await reg.ensureAgentSlug("email:b@x.com")).toEqual({ slug: "nicholas-j-2" }); + expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "nicholas-j" }); + }); + + it("ensureAgentSlug without a profile errors", async () => { + const reg = makeRegistry(); + expect(await reg.ensureAgentSlug("email:ghost@x.com")).toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("auth codes are single use and expire", async () => { + const reg = makeRegistry(); + const { code } = await reg.putCode({ + clientId: "c1", + principal: "email:a@x.com", + caps: ["suggest", "comment"], + codeChallenge: "challenge", + redirectUri: "https://client/cb", + }); + const first = await reg.takeCode(code); + expect(first.data?.principal).toBe("email:a@x.com"); + const second = await reg.takeCode(code); + expect(second.data).toBeNull(); + }); + + it("refresh tokens rotate; the old token dies; revoke kills the new one", async () => { + const reg = makeRegistry(); + const { token } = await reg.putRefresh({ + clientId: "c1", + principal: "email:a@x.com", + caps: ["suggest", "comment", "write"], + }); + const rotated = await reg.rotateRefresh(token); + expect("token" in rotated && rotated.data.caps).toContain("write"); + expect(await reg.rotateRefresh(token)).toMatchObject({ error: { code: "invalid_grant" } }); + if ("token" in rotated) { + await reg.revokeRefresh(rotated.token); + expect(await reg.rotateRefresh(rotated.token)).toMatchObject({ + error: { code: "invalid_grant" }, + }); + } + }); + + it("registers and fetches oauth clients", async () => { + const reg = makeRegistry(); + const { client } = await reg.registerClient({ + name: "Claude Code", + redirectUris: ["https://claude.ai/cb"], + }); + const fetched = await reg.getClient(client.clientId); + expect(fetched.client?.name).toBe("Claude Code"); + expect((await reg.getClient("nope")).client).toBeNull(); + }); +}); diff --git a/workers/app.ts b/workers/app.ts index 729eebaf..d367a553 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -11,6 +11,7 @@ import { } from "./routes"; export { default as DocumentAgent } from "../agents/document"; +export { default as Registry } from "../agents/registry"; export { VaporMcp }; const requestHandler = createRequestHandler( diff --git a/wrangler.jsonc b/wrangler.jsonc index 0712ebbb..1b876e2f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -16,12 +16,14 @@ "durable_objects": { "bindings": [ { "name": "DocumentAgent", "class_name": "DocumentAgent" }, - { "name": "VaporMcp", "class_name": "VaporMcp" } + { "name": "VaporMcp", "class_name": "VaporMcp" }, + { "name": "Registry", "class_name": "Registry" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["DocumentAgent"] }, - { "tag": "v2", "new_sqlite_classes": ["VaporMcp"] } + { "tag": "v2", "new_sqlite_classes": ["VaporMcp"] }, + { "tag": "v3", "new_sqlite_classes": ["Registry"] } ], "keep_vars": true } From b2da398ec5f2e8f3defe820afb32bd6c9926cab8 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:51:10 -0700 Subject: [PATCH 46/83] Add auth routes for Google sign-in sessions Co-Authored-By: Claude Fable 5 --- tests/unit/agents/worker-routes.test.ts | 96 ++++++++++++++++++++++ workers/app.ts | 22 +++++ workers/env.d.ts | 11 +++ workers/routes.ts | 102 ++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 workers/env.d.ts diff --git a/tests/unit/agents/worker-routes.test.ts b/tests/unit/agents/worker-routes.test.ts index 74c39f4e..c44b3e3e 100644 --- a/tests/unit/agents/worker-routes.test.ts +++ b/tests/unit/agents/worker-routes.test.ts @@ -5,6 +5,7 @@ import { redirectHost, redirectLegacyDocPath, } from "../../../workers/routes"; +import * as routesModule from "../../../workers/routes"; describe("handleRawMarkdown", () => { it("returns 200 with text/markdown for an existing doc", async () => { @@ -230,3 +231,98 @@ describe("redirectHost", () => { expect(res).toBeNull(); }); }); + +describe("handleAuth", () => { + const { handleAuth } = routesModule; + + function deps(overrides: Partial[1]> = {}) { + return { + secret: "test-secret", + googleClientId: "client-123", + verifyGoogle: vi.fn(async () => ({ + email: "Nicholas@Artifact.com", + name: "Nicholas", + picture: "https://p/x.png", + })), + upsertProfile: vi.fn(async () => ({ + profile: { displayName: "Nicholas", agentSlug: null }, + })), + getProfile: vi.fn(async () => ({ + profile: { displayName: "Nicholas", agentSlug: "nicholas" }, + })), + ...overrides, + }; + } + + function googlePost(origin = "https://vapor.fyi") { + return new Request("https://vapor.fyi/auth/google", { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json" }, + body: JSON.stringify({ credential: "tok" }), + }); + } + + it("returns null for non-auth paths", async () => { + expect(await handleAuth(new Request("https://vapor.fyi/other"), deps())).toBeNull(); + }); + + it("config returns the public client id", async () => { + const res = await handleAuth(new Request("https://vapor.fyi/auth/config"), deps()); + expect(await res?.json()).toEqual({ googleClientId: "client-123" }); + }); + + it("google happy path sets a secure session cookie and lowercases the principal", async () => { + const d = deps(); + const res = await handleAuth(googlePost(), d); + expect(res?.status).toBe(200); + const cookie = res?.headers.get("Set-Cookie") ?? ""; + expect(cookie).toContain("vp_session="); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Secure"); + expect(d.upsertProfile).toHaveBeenCalledWith( + "email:nicholas@artifact.com", + expect.objectContaining({ displayName: "Nicholas" }), + ); + }); + + it("rejects cross-origin sign-in", async () => { + const res = await handleAuth(googlePost("https://evil.example"), deps()); + expect(res?.status).toBe(403); + }); + + it("rejects a bad credential", async () => { + const res = await handleAuth( + googlePost(), + deps({ verifyGoogle: vi.fn(async () => null) }), + ); + expect(res?.status).toBe(401); + }); + + it("me without a session reports signedIn false", async () => { + const res = await handleAuth(new Request("https://vapor.fyi/auth/me"), deps()); + expect(await res?.json()).toEqual({ signedIn: false }); + }); + + it("me with a session cookie returns the profile", async () => { + const d = deps(); + const signIn = await handleAuth(googlePost(), d); + const cookie = (signIn?.headers.get("Set-Cookie") ?? "").split(";")[0]; + const res = await handleAuth( + new Request("https://vapor.fyi/auth/me", { headers: { Cookie: cookie } }), + d, + ); + const body = (await res?.json()) as Record; + expect(body.signedIn).toBe(true); + expect(body.principal).toBe("email:nicholas@artifact.com"); + expect(body.agentSlug).toBe("nicholas"); + }); + + it("logout clears the cookie", async () => { + const res = await handleAuth( + new Request("https://vapor.fyi/auth/logout", { method: "POST" }), + deps(), + ); + expect(res?.headers.get("Set-Cookie")).toContain("Max-Age=0"); + }); +}); diff --git a/workers/app.ts b/workers/app.ts index d367a553..7e247bc2 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -5,10 +5,13 @@ import { VaporMcp, type VaporMcpProps } from "../agents/mcp"; import { handleRawMarkdown, handleMcpHelp, + handleAuth, redirectHost, redirectLegacyDocPath, type MarkdownStub, } from "./routes"; +import { verifyGoogleIdToken } from "../app/lib/auth.server"; +import type Registry from "../agents/registry"; export { default as DocumentAgent } from "../agents/document"; export { default as Registry } from "../agents/registry"; @@ -40,6 +43,25 @@ export default { return legacyDocResponse; } + // /auth/* — Google sign-in sessions. Optional everywhere; only mints and + // reads the vp_session cookie. + if (url.pathname.startsWith("/auth/")) { + const registry = (await getAgentByName( + env.Registry, + "global", + )) as unknown as Registry; + const authResponse = await handleAuth(request, { + secret: env.SESSION_SECRET ?? "", + googleClientId: env.GOOGLE_CLIENT_ID ?? "", + verifyGoogle: verifyGoogleIdToken, + upsertProfile: (principal, info) => registry.upsertProfile(principal, info), + getProfile: (principal) => registry.getProfile(principal), + }); + if (authResponse) { + return authResponse; + } + } + // A browser landing on /mcp (Accept: text/html) gets a how-to-connect // page instead of a protocol error. MCP clients send an // application/json-flavoured Accept and never match this, so they fall diff --git a/workers/env.d.ts b/workers/env.d.ts new file mode 100644 index 00000000..c6cd4c7a --- /dev/null +++ b/workers/env.d.ts @@ -0,0 +1,11 @@ +// Bindings that exist at runtime but aren't derivable from wrangler.jsonc: +// SESSION_SECRET is a Workers secret (`wrangler secret put SESSION_SECRET`; +// locally via .dev.vars) and GOOGLE_CLIENT_ID is set as a plain var at +// deploy time / in .dev.vars. Declared optional so code handles their +// absence explicitly. +declare namespace Cloudflare { + interface Env { + SESSION_SECRET?: string; + GOOGLE_CLIENT_ID?: string; + } +} diff --git a/workers/routes.ts b/workers/routes.ts index a00be2f9..7ff716be 100644 --- a/workers/routes.ts +++ b/workers/routes.ts @@ -9,6 +9,14 @@ import { isValidDocumentId } from "../app/shared/constants"; import type { AgentError } from "../app/shared/agent-protocol"; import { mcpHelpHtml } from "../app/lib/mcp-help"; +import { + mintSessionToken, + sessionFromRequest, + sessionCookieHeader, + clearSessionCookieHeader, + sameOrigin, + principalFromEmail, +} from "../app/lib/auth.server"; /** The subset of the DocumentAgent RPC surface handleRawMarkdown calls. */ export interface MarkdownStub { @@ -108,6 +116,100 @@ export function redirectHost(request: Request): Response | null { }); } +const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; + +/** Dependencies handleAuth needs, injected from workers/app.ts. */ +export interface AuthDeps { + secret: string; + googleClientId: string; + /** Injectable for tests; production passes verifyGoogleIdToken. */ + verifyGoogle: ( + credential: string, + clientId: string, + ) => Promise<{ email: string; name: string; picture?: string } | null>; + upsertProfile: ( + principal: string, + info: { displayName: string; avatar?: string }, + ) => Promise<{ profile: { displayName: string; agentSlug: string | null } }>; + getProfile: ( + principal: string, + ) => Promise<{ profile: { displayName: string; agentSlug: string | null } | null }>; +} + +function json(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +/** + * `/auth/*` — Google sign-in sessions. Returns null for non-auth paths so + * the worker falls through. Sign-in is optional everywhere; these routes + * only mint and read the `vp_session` cookie. + */ +export async function handleAuth(request: Request, deps: AuthDeps): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith("/auth/")) return null; + const secure = url.protocol === "https:"; + + if (request.method === "GET" && url.pathname === "/auth/config") { + return json({ googleClientId: deps.googleClientId }); + } + + if (request.method === "GET" && url.pathname === "/auth/me") { + const session = await sessionFromRequest(request, deps.secret); + if (!session) return json({ signedIn: false }); + const { profile } = await deps.getProfile(session.principal); + return json({ + signedIn: true, + principal: session.principal, + email: session.email, + displayName: profile?.displayName ?? session.email, + agentSlug: profile?.agentSlug ?? null, + }); + } + + if (request.method === "POST" && url.pathname === "/auth/logout") { + return json({ ok: true }, 200, { "Set-Cookie": clearSessionCookieHeader(secure) }); + } + + if (request.method === "POST" && url.pathname === "/auth/google") { + if (!sameOrigin(request)) { + return json({ error: "cross-origin sign-in rejected" }, 403); + } + let credential: string | undefined; + try { + const body = (await request.json()) as { credential?: string }; + credential = body.credential; + } catch { + return json({ error: "invalid body" }, 400); + } + if (!credential) return json({ error: "missing credential" }, 400); + + const verified = await deps.verifyGoogle(credential, deps.googleClientId); + if (!verified) return json({ error: "invalid credential" }, 401); + + const principal = principalFromEmail(verified.email); + const { profile } = await deps.upsertProfile(principal, { + displayName: verified.name || verified.email, + avatar: verified.picture, + }); + const token = await mintSessionToken( + { principal, email: verified.email.toLowerCase() }, + deps.secret, + SESSION_TTL_SECONDS, + ); + return json( + { signedIn: true, principal, displayName: profile.displayName }, + 200, + { "Set-Cookie": sessionCookieHeader(token, SESSION_TTL_SECONDS, secure) }, + ); + } + + return null; +} + /** * `GET /docs/:id` and `GET /docs/:id.md` — permanent redirects to the current * root-level document URLs (`/:id`, `/:id.md`). From b50f67346ae9957274fd6a118c852b46e968b91b Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:54:32 -0700 Subject: [PATCH 47/83] Add OAuth 2.1 authorization server for MCP clients Ported from subpixel server/oauth.ts: PKCE S256, dynamic client registration, single-use codes, rotating hashed refresh tokens in the Registry DO, discovery documents, and a consent page that carries the capability grant (suggest+comment default, full write opt-in). Access tokens are 1-hour session JWTs carrying the granted caps. Co-Authored-By: Claude Fable 5 --- agents/registry.ts | 29 +- app/lib/oauth-pages.ts | 89 ++++++ tests/integration/agents/registry.test.ts | 4 + tests/unit/agents/oauth.test.ts | 332 ++++++++++++++++++++ workers/app.ts | 17 ++ workers/oauth.ts | 354 ++++++++++++++++++++++ 6 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 app/lib/oauth-pages.ts create mode 100644 tests/unit/agents/oauth.test.ts create mode 100644 workers/oauth.ts diff --git a/agents/registry.ts b/agents/registry.ts index 5e36ea0d..c3f6223e 100644 --- a/agents/registry.ts +++ b/agents/registry.ts @@ -30,6 +30,7 @@ export interface OAuthClient { export interface AuthCode { clientId: string; principal: string; + email: string; caps: AgentCapability[]; codeChallenge: string; redirectUri: string; @@ -39,6 +40,7 @@ export interface AuthCode { export interface RefreshGrant { clientId: string; principal: string; + email: string; caps: AgentCapability[]; exp: number; } @@ -46,6 +48,11 @@ export interface RefreshGrant { const CODE_TTL_MS = 10 * 60 * 1000; const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000; +async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + function randomToken(prefix: string): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -182,30 +189,40 @@ class Registry extends Agent { return { data }; } + /** Refresh tokens are hashed at rest (subpixel convention): a Registry + * dump never yields usable credentials. Callers hold the raw token. */ async putRefresh( data: Omit, ): Promise<{ token: string }> { const token = randomToken("var_"); - this.kvPut(`rt:${token}`, { ...data, exp: Date.now() + REFRESH_TTL_MS } satisfies RefreshGrant); + this.kvPut(`rt:${await sha256Hex(token)}`, { + ...data, + exp: Date.now() + REFRESH_TTL_MS, + } satisfies RefreshGrant); return { token }; } - /** Rotation: the old token is consumed; a fresh one is issued for the same grant. */ + /** Rotation: the old (raw) token is consumed; a fresh one is issued for + * the same grant. No family-replay revocation this phase. */ async rotateRefresh( oldToken: string, ): Promise<{ token: string; data: RefreshGrant } | { error: { code: string; message: string } }> { - const data = this.kvGet(`rt:${oldToken}`); - this.kvDelete(`rt:${oldToken}`); + const oldKey = `rt:${await sha256Hex(oldToken)}`; + const data = this.kvGet(oldKey); + this.kvDelete(oldKey); if (!data || data.exp < Date.now()) { return { error: { code: "invalid_grant", message: "Refresh token is unknown or expired" } }; } const token = randomToken("var_"); - this.kvPut(`rt:${token}`, { ...data, exp: Date.now() + REFRESH_TTL_MS } satisfies RefreshGrant); + this.kvPut(`rt:${await sha256Hex(token)}`, { + ...data, + exp: Date.now() + REFRESH_TTL_MS, + } satisfies RefreshGrant); return { token, data }; } async revokeRefresh(token: string): Promise<{ ok: true }> { - this.kvDelete(`rt:${token}`); + this.kvDelete(`rt:${await sha256Hex(token)}`); return { ok: true }; } } diff --git a/app/lib/oauth-pages.ts b/app/lib/oauth-pages.ts new file mode 100644 index 00000000..1cf773f7 --- /dev/null +++ b/app/lib/oauth-pages.ts @@ -0,0 +1,89 @@ +/** + * The OAuth consent page: a signed-in user approves an MCP client and + * chooses its capability grant; a signed-out visitor gets inline Google + * sign-in first (same GSI flow the header uses). Styling matches the /mcp + * help page. Ported from subpixel server/oauth.ts's consentPage. + */ + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c, + ); +} + +export function consentPageHtml(opts: { + clientName: string; + email: string | null; + params: Record; + error?: string; +}): string { + const { clientName, email, params, error } = opts; + const hidden = Object.entries(params) + .map(([k, v]) => ``) + .join("\n "); + + const body = error + ? `

${escapeHtml(error)}

` + : email + ? `

${escapeHtml(clientName)} wants to join vapor documents as your agent, acting as ${escapeHtml(email)}.

+
+ ${hidden} + + +
+ + +
+
` + : `

${escapeHtml(clientName)} wants to connect to vapor. Sign in to continue.

+
+ `; + + return ` + + +vapor — connect + +
+

vapor

+ ${body} +
`; +} diff --git a/tests/integration/agents/registry.test.ts b/tests/integration/agents/registry.test.ts index 65534c70..87a42add 100644 --- a/tests/integration/agents/registry.test.ts +++ b/tests/integration/agents/registry.test.ts @@ -85,6 +85,7 @@ describe("Registry", () => { const { code } = await reg.putCode({ clientId: "c1", principal: "email:a@x.com", + email: "a@x.com", caps: ["suggest", "comment"], codeChallenge: "challenge", redirectUri: "https://client/cb", @@ -100,8 +101,11 @@ describe("Registry", () => { const { token } = await reg.putRefresh({ clientId: "c1", principal: "email:a@x.com", + email: "a@x.com", caps: ["suggest", "comment", "write"], }); + // hashed at rest: the raw token never appears as a storage key + expect([...kvStore.keys()].some((k) => k.includes(token))).toBe(false); const rotated = await reg.rotateRefresh(token); expect("token" in rotated && rotated.data.caps).toContain("write"); expect(await reg.rotateRefresh(token)).toMatchObject({ error: { code: "invalid_grant" } }); diff --git a/tests/unit/agents/oauth.test.ts b/tests/unit/agents/oauth.test.ts new file mode 100644 index 00000000..c61aca44 --- /dev/null +++ b/tests/unit/agents/oauth.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from "vitest"; +import { handleOAuth, type OAuthRegistry } from "../../../workers/oauth"; +import { mintSessionToken, verifySessionToken, SESSION_COOKIE } from "../../../app/lib/auth.server"; +import type { AuthCode, OAuthClient, RefreshGrant } from "../../../agents/registry"; + +const SECRET = "oauth-test-secret"; + +/** In-memory OAuthRegistry fake mirroring the real Registry semantics. */ +function fakeRegistry(): OAuthRegistry & { codes: Map } { + const clients = new Map(); + const codes = new Map(); + const refresh = new Map(); + let n = 0; + return { + codes, + async registerClient(info) { + const client: OAuthClient = { + clientId: `client-${++n}`, + name: info.name, + redirectUris: info.redirectUris, + createdAt: 0, + }; + clients.set(client.clientId, client); + return { client }; + }, + async getClient(clientId) { + return { client: clients.get(clientId) ?? null }; + }, + async putCode(data) { + const code = `code-${++n}`; + codes.set(code, { ...data, exp: Date.now() + 60_000 }); + return { code }; + }, + async takeCode(code) { + const data = codes.get(code) ?? null; + codes.delete(code); + return { data }; + }, + async putRefresh(data) { + const token = `refresh-${++n}`; + refresh.set(token, { ...data, exp: Date.now() + 60_000 }); + return { token }; + }, + async rotateRefresh(oldToken) { + const data = refresh.get(oldToken); + refresh.delete(oldToken); + if (!data) return { error: { code: "invalid_grant", message: "unknown" } }; + const token = `refresh-${++n}`; + refresh.set(token, data); + return { token, data }; + }, + async revokeRefresh(token) { + refresh.delete(token); + return { ok: true }; + }, + }; +} + +async function pkcePair() { + const verifier = "v".repeat(43); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return { verifier, challenge }; +} + +function deps(registry: OAuthRegistry) { + return { secret: SECRET, registry }; +} + +const REDIRECT = "https://claude.ai/api/mcp/auth_callback"; + +async function registeredClient(registry: OAuthRegistry): Promise { + const res = await handleOAuth( + new Request("https://vapor.fyi/oauth/register", { + method: "POST", + body: JSON.stringify({ client_name: "Claude", redirect_uris: [REDIRECT] }), + }), + deps(registry), + ); + const body = (await res?.json()) as { client_id: string }; + return body.client_id; +} + +describe("oauth authorization server", () => { + it("serves discovery documents with CORS", async () => { + const res = await handleOAuth( + new Request("https://vapor.fyi/.well-known/oauth-authorization-server"), + deps(fakeRegistry()), + ); + const meta = (await res?.json()) as Record; + expect(meta.issuer).toBe("https://vapor.fyi"); + expect(meta.code_challenge_methods_supported).toEqual(["S256"]); + expect(res?.headers.get("access-control-allow-origin")).toBe("*"); + + const resource = await handleOAuth( + new Request("https://vapor.fyi/.well-known/oauth-protected-resource/mcp"), + deps(fakeRegistry()), + ); + expect(((await resource?.json()) as Record).resource).toBe( + "https://vapor.fyi/mcp", + ); + }); + + it("registers clients and rejects bad redirect uris", async () => { + const registry = fakeRegistry(); + const clientId = await registeredClient(registry); + expect(clientId).toMatch(/^client-/); + + const bad = await handleOAuth( + new Request("https://vapor.fyi/oauth/register", { + method: "POST", + body: JSON.stringify({ redirect_uris: ["http://evil.example/cb"] }), + }), + deps(registry), + ); + expect(bad?.status).toBe(400); + }); + + it("authorize without a session serves the sign-in consent page", async () => { + const registry = fakeRegistry(); + const clientId = await registeredClient(registry); + const { challenge } = await pkcePair(); + const res = await handleOAuth( + new Request( + `https://vapor.fyi/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(REDIRECT)}&response_type=code&code_challenge=${challenge}&code_challenge_method=S256&state=xyz`, + ), + deps(registry), + ); + const html = (await res?.text()) ?? ""; + expect(html).toContain("accounts.google.com/gsi/client"); + expect(html).toContain("Claude"); + }); + + it("unknown client never redirects", async () => { + const res = await handleOAuth( + new Request("https://vapor.fyi/oauth/authorize?client_id=nope&redirect_uri=https%3A%2F%2Fevil"), + deps(fakeRegistry()), + ); + expect(res?.status).toBe(400); + expect(res?.headers.get("Location")).toBeNull(); + }); + + it("full code + PKCE exchange carries the chosen capabilities", async () => { + const registry = fakeRegistry(); + const clientId = await registeredClient(registry); + const { verifier, challenge } = await pkcePair(); + const session = await mintSessionToken( + { principal: "email:nicholas@artifact.com", email: "nicholas@artifact.com" }, + SECRET, + ); + + const approve = await handleOAuth( + new Request("https://vapor.fyi/oauth/authorize", { + method: "POST", + headers: { Cookie: `${SESSION_COOKIE}=${session}` }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: REDIRECT, + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "xyz", + decision: "approve", + caps: "write", + }).toString(), + }), + deps(registry), + ); + expect(approve?.status).toBe(302); + const location = new URL(approve?.headers.get("Location") ?? ""); + const code = location.searchParams.get("code"); + expect(code).toBeTruthy(); + expect(location.searchParams.get("state")).toBe("xyz"); + + const tokenRes = await handleOAuth( + new Request("https://vapor.fyi/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code ?? "", + code_verifier: verifier, + client_id: clientId, + redirect_uri: REDIRECT, + }).toString(), + }), + deps(registry), + ); + const tokens = (await tokenRes?.json()) as Record; + expect(tokens.token_type).toBe("Bearer"); + const claims = await verifySessionToken(tokens.access_token, SECRET); + expect(claims?.principal).toBe("email:nicholas@artifact.com"); + expect(claims?.caps).toEqual(["suggest", "comment", "write"]); + + // refresh rotation + const refreshed = await handleOAuth( + new Request("https://vapor.fyi/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + }).toString(), + }), + deps(registry), + ); + const rotated = (await refreshed?.json()) as Record; + expect(rotated.refresh_token).not.toBe(tokens.refresh_token); + const replay = await handleOAuth( + new Request("https://vapor.fyi/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + }).toString(), + }), + deps(registry), + ); + expect(replay?.status).toBe(400); + }); + + it("wrong PKCE verifier and code reuse both fail", async () => { + const registry = fakeRegistry(); + const clientId = await registeredClient(registry); + const { verifier, challenge } = await pkcePair(); + const session = await mintSessionToken( + { principal: "email:a@x.com", email: "a@x.com" }, + SECRET, + ); + const approve = await handleOAuth( + new Request("https://vapor.fyi/oauth/authorize", { + method: "POST", + headers: { Cookie: `${SESSION_COOKIE}=${session}` }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: REDIRECT, + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + decision: "approve", + }).toString(), + }), + deps(registry), + ); + const code = new URL(approve?.headers.get("Location") ?? "").searchParams.get("code") ?? ""; + + const wrongVerifier = await handleOAuth( + new Request("https://vapor.fyi/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: "w".repeat(43), + client_id: clientId, + redirect_uri: REDIRECT, + }).toString(), + }), + deps(registry), + ); + expect(wrongVerifier?.status).toBe(400); + + // the code was consumed by the failed attempt (single use) + const reuse = await handleOAuth( + new Request("https://vapor.fyi/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: clientId, + redirect_uri: REDIRECT, + }).toString(), + }), + deps(registry), + ); + expect(reuse?.status).toBe(400); + }); + + it("deny redirects with access_denied; default caps are suggest+comment", async () => { + const registry = fakeRegistry(); + const clientId = await registeredClient(registry); + const { challenge } = await pkcePair(); + const session = await mintSessionToken( + { principal: "email:a@x.com", email: "a@x.com" }, + SECRET, + ); + const base = { + client_id: clientId, + redirect_uri: REDIRECT, + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + }; + const deny = await handleOAuth( + new Request("https://vapor.fyi/oauth/authorize", { + method: "POST", + headers: { Cookie: `${SESSION_COOKIE}=${session}` }, + body: new URLSearchParams({ ...base, decision: "deny" }).toString(), + }), + deps(registry), + ); + expect(new URL(deny?.headers.get("Location") ?? "").searchParams.get("error")).toBe( + "access_denied", + ); + + const approve = await handleOAuth( + new Request("https://vapor.fyi/oauth/authorize", { + method: "POST", + headers: { Cookie: `${SESSION_COOKIE}=${session}` }, + body: new URLSearchParams({ ...base, decision: "approve" }).toString(), + }), + deps(registry), + ); + const code = new URL(approve?.headers.get("Location") ?? "").searchParams.get("code") ?? ""; + expect(registry.codes.get(code)?.caps ?? (await registry.takeCode(code)).data?.caps).toEqual([ + "suggest", + "comment", + ]); + }); + + it("revoke always returns 200", async () => { + const res = await handleOAuth( + new Request("https://vapor.fyi/oauth/revoke", { + method: "POST", + body: new URLSearchParams({ token: "whatever" }).toString(), + }), + deps(fakeRegistry()), + ); + expect(res?.status).toBe(200); + }); +}); diff --git a/workers/app.ts b/workers/app.ts index 7e247bc2..bcedf276 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -11,6 +11,7 @@ import { type MarkdownStub, } from "./routes"; import { verifyGoogleIdToken } from "../app/lib/auth.server"; +import { handleOAuth } from "./oauth"; import type Registry from "../agents/registry"; export { default as DocumentAgent } from "../agents/document"; @@ -43,6 +44,22 @@ export default { return legacyDocResponse; } + // /oauth/* + the OAuth discovery documents — the authorization server + // MCP clients use to connect with the user's identity. + if (url.pathname.startsWith("/oauth") || url.pathname.startsWith("/.well-known/oauth-")) { + const registry = (await getAgentByName( + env.Registry, + "global", + )) as unknown as Registry; + const oauthResponse = await handleOAuth(request, { + secret: env.SESSION_SECRET ?? "", + registry, + }); + if (oauthResponse) { + return oauthResponse; + } + } + // /auth/* — Google sign-in sessions. Optional everywhere; only mints and // reads the vp_session cookie. if (url.pathname.startsWith("/auth/")) { diff --git a/workers/oauth.ts b/workers/oauth.ts new file mode 100644 index 00000000..b531af05 --- /dev/null +++ b/workers/oauth.ts @@ -0,0 +1,354 @@ +/** + * A minimal OAuth 2.1 authorization server so any MCP client can connect to + * /mcp with the user's own identity. Ported from subpixel server/oauth.ts: + * - access tokens ARE vapor's HMAC session JWTs (1h TTL, carrying the + * granted capabilities), verified by the same code path everywhere; + * - clients, single-use codes, and rotating hashed refresh tokens live in + * the Registry DO; + * - public clients only: PKCE S256 required, no client secrets. + * Dependency-injected (no `agents` package import) so it unit-tests in + * plain Vitest; workers/app.ts supplies the Registry stub. + */ +import { + mintSessionToken, + sessionFromRequest, + type SessionClaims, +} from "../app/lib/auth.server"; +import { consentPageHtml } from "../app/lib/oauth-pages"; +import { DEFAULT_CAPABILITIES } from "../app/shared/agent-protocol"; +import type { AgentCapability } from "../app/shared/agent-protocol"; +import type { AuthCode, OAuthClient, RefreshGrant } from "../agents/registry"; + +export interface OAuthRegistry { + registerClient(info: { name: string; redirectUris: string[] }): Promise<{ client: OAuthClient }>; + getClient(clientId: string): Promise<{ client: OAuthClient | null }>; + putCode(data: Omit): Promise<{ code: string }>; + takeCode(code: string): Promise<{ data: AuthCode | null }>; + putRefresh(data: Omit): Promise<{ token: string }>; + rotateRefresh( + oldToken: string, + ): Promise<{ token: string; data: RefreshGrant } | { error: { code: string; message: string } }>; + revokeRefresh(token: string): Promise<{ ok: true }>; +} + +export interface OAuthDeps { + secret: string; + registry: OAuthRegistry; +} + +const ACCESS_TTL_SECONDS = 60 * 60; +const MAX_CLIENT_NAME = 64; +const MAX_REDIRECT_URIS = 8; + +const WRITE_CAPS: AgentCapability[] = ["suggest", "comment", "write"]; + +async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +// https redirects only; localhost/127.0.0.1 excepted for native + dev clients +function validRedirectUri(uri: unknown): uri is string { + if (typeof uri !== "string" || uri.length > 512) return false; + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + return false; + } + if (parsed.protocol === "https:") return true; + return ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") + ); +} + +function oauthError(status: number, error: string, description: string): Response { + return Response.json({ error, error_description: description }, { status }); +} + +function serverMetadata(origin: string) { + return { + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/oauth/token`, + registration_endpoint: `${origin}/oauth/register`, + revocation_endpoint: `${origin}/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: [], + service_documentation: `${origin}/mcp`, + }; +} + +function consentResponse(opts: Parameters[0]): Response { + return new Response(consentPageHtml(opts), { + status: opts.error ? 400 : 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} + +async function handleRegister(request: Request, deps: OAuthDeps): Promise { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return oauthError(400, "invalid_client_metadata", "body must be JSON"); + } + const uris: unknown = body?.redirect_uris; + if ( + !Array.isArray(uris) || + uris.length === 0 || + uris.length > MAX_REDIRECT_URIS || + !uris.every(validRedirectUri) + ) { + return oauthError(400, "invalid_redirect_uri", "redirect_uris must be https (or localhost) URLs"); + } + const name = + typeof body.client_name === "string" ? body.client_name.slice(0, MAX_CLIENT_NAME) : "an MCP client"; + const { client } = await deps.registry.registerClient({ name, redirectUris: uris as string[] }); + return Response.json( + { + client_id: client.clientId, + redirect_uris: uris, + client_name: name, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }, + { status: 201 }, + ); +} + +// Validation order matters: an unknown client or unregistered redirect must +// NEVER redirect (that would be an open redirector); every later error DOES +// redirect with ?error= per RFC 6749. +function redirectWith(redirectUri: string, extra: Record, state: string | null): Response { + const url = new URL(redirectUri); + for (const [k, v] of Object.entries(extra)) url.searchParams.set(k, v); + if (state) url.searchParams.set("state", state); + return Response.redirect(url.toString(), 302); +} + +async function handleAuthorize(request: Request, deps: OAuthDeps): Promise { + const url = new URL(request.url); + const params = + request.method === "POST" ? new URLSearchParams(await request.text()) : url.searchParams; + + const clientId = params.get("client_id") ?? ""; + const redirectUri = params.get("redirect_uri") ?? ""; + const { client } = clientId + ? await deps.registry.getClient(clientId) + : { client: null }; + if (!client) { + return consentResponse({ clientName: "unknown", email: null, params: {}, error: "unknown client_id" }); + } + if (!client.redirectUris.includes(redirectUri)) { + return consentResponse({ + clientName: client.name, + email: null, + params: {}, + error: "redirect_uri is not registered for this client", + }); + } + + const state = params.get("state"); + if (params.get("response_type") !== "code") { + return redirectWith(redirectUri, { error: "unsupported_response_type" }, state); + } + const codeChallenge = params.get("code_challenge") ?? ""; + if ( + !/^[A-Za-z0-9_-]{43,128}$/.test(codeChallenge) || + (params.get("code_challenge_method") ?? "S256") !== "S256" + ) { + return redirectWith( + redirectUri, + { error: "invalid_request", error_description: "PKCE S256 is required" }, + state, + ); + } + + const session = await sessionFromRequest(request, deps.secret); + const passthrough: Record = {}; + for (const key of [ + "client_id", + "redirect_uri", + "response_type", + "code_challenge", + "code_challenge_method", + "state", + "scope", + ]) { + const v = params.get(key); + if (v !== null) passthrough[key] = v; + } + + if (!session) { + return consentResponse({ clientName: client.name, email: null, params: passthrough }); + } + if (request.method === "GET") { + return consentResponse({ clientName: client.name, email: session.email, params: passthrough }); + } + + // POST with a live session: the decision (same-origin form + SameSite + // cookie makes cross-site forgery a non-starter) + if (params.get("decision") !== "approve") { + return redirectWith(redirectUri, { error: "access_denied" }, state); + } + const caps: AgentCapability[] = + params.get("caps") === "write" ? WRITE_CAPS : [...DEFAULT_CAPABILITIES]; + const { code } = await deps.registry.putCode({ + principal: session.principal, + email: session.email, + caps, + clientId, + redirectUri, + codeChallenge, + }); + return redirectWith(redirectUri, { code }, state); +} + +async function mintTokens( + deps: OAuthDeps, + grant: { principal: string; email: string; caps: AgentCapability[]; clientId: string }, +): Promise { + const accessToken = await mintSessionToken( + { principal: grant.principal, email: grant.email, caps: grant.caps } as Omit< + SessionClaims, + "iat" | "exp" + >, + deps.secret, + ACCESS_TTL_SECONDS, + ); + const { token: refreshToken } = await deps.registry.putRefresh({ + principal: grant.principal, + email: grant.email, + caps: grant.caps, + clientId: grant.clientId, + }); + return Response.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TTL_SECONDS, + refresh_token: refreshToken, + scope: "", + }); +} + +async function handleToken(request: Request, deps: OAuthDeps): Promise { + const params = new URLSearchParams(await request.text()); + const grantType = params.get("grant_type"); + + if (grantType === "authorization_code") { + const code = params.get("code") ?? ""; + const verifier = params.get("code_verifier") ?? ""; + const { data } = code ? await deps.registry.takeCode(code) : { data: null }; + if (!data) return oauthError(400, "invalid_grant", "unknown, expired, or already-used code"); + if (data.clientId !== params.get("client_id") || data.redirectUri !== params.get("redirect_uri")) { + return oauthError(400, "invalid_grant", "code is bound to a different client or redirect_uri"); + } + if (!verifier || (await sha256Base64Url(verifier)) !== data.codeChallenge) { + return oauthError(400, "invalid_grant", "PKCE verification failed"); + } + return mintTokens(deps, data); + } + + if (grantType === "refresh_token") { + const token = params.get("refresh_token") ?? ""; + const rotated = token ? await deps.registry.rotateRefresh(token) : null; + if (!rotated || "error" in rotated) { + return oauthError(400, "invalid_grant", "refresh token is unknown, expired, or revoked"); + } + // rotateRefresh already issued the replacement; hand it out with a + // fresh access token for the same grant. + const accessToken = await mintSessionToken( + { + principal: rotated.data.principal, + email: rotated.data.email, + caps: rotated.data.caps, + } as Omit, + deps.secret, + ACCESS_TTL_SECONDS, + ); + return Response.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TTL_SECONDS, + refresh_token: rotated.token, + scope: "", + }); + } + + return oauthError(400, "unsupported_grant_type", "use authorization_code or refresh_token"); +} + +async function handleRevoke(request: Request, deps: OAuthDeps): Promise { + const params = new URLSearchParams(await request.text()); + const token = params.get("token") ?? ""; + if (token) await deps.registry.revokeRefresh(token); + return new Response(null, { status: 200 }); // RFC 7009: always succeed +} + +// OAuth endpoints and discovery docs are fetched cross-origin by MCP +// clients' web frontends (claude.ai does registration + token exchange from +// the browser) — without CORS the flow fails silently after consent. +export const OAUTH_CORS: Record = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "content-type, authorization, mcp-protocol-version, mcp-session-id", + "access-control-max-age": "86400", +}; + +function withCors(res: Response): Response { + const out = new Response(res.body, res); + for (const [k, v] of Object.entries(OAUTH_CORS)) out.headers.set(k, v); + return out; +} + +export async function handleOAuth(request: Request, deps: OAuthDeps): Promise { + const url = new URL(request.url); + const path = url.pathname.replace(/\/$/, ""); + const origin = url.origin; + + // Discovery is path-aware (RFC 8414 / MCP auth spec): a client connecting + // to /mcp asks for /.well-known/oauth-protected-resource/mcp and + // expects `resource` to equal that exact endpoint URL. Serve the bare + // documents and any path-suffixed variant of them. + const wellKnown = path.match( + /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)(\/.*)?$/, + ); + if (wellKnown) { + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: OAUTH_CORS }); + const [, doc, suffix] = wellKnown; + if (doc === "oauth-authorization-server") return withCors(Response.json(serverMetadata(origin))); + return withCors( + Response.json({ + resource: origin + (suffix ?? ""), + authorization_servers: [origin], + bearer_methods_supported: ["header"], + }), + ); + } + + if (path.startsWith("/oauth/")) { + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: OAUTH_CORS }); + if (path === "/oauth/register" && request.method === "POST") { + return withCors(await handleRegister(request, deps)); + } + if (path === "/oauth/authorize" && (request.method === "GET" || request.method === "POST")) { + return handleAuthorize(request, deps); // top-level navigation, no CORS needed + } + if (path === "/oauth/token" && request.method === "POST") { + return withCors(await handleToken(request, deps)); + } + if (path === "/oauth/revoke" && request.method === "POST") { + return withCors(await handleRevoke(request, deps)); + } + } + return null; +} From a7a7193a7287f5125783c58705798f9a80b4764d Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:14:50 -0700 Subject: [PATCH 48/83] Replace per-doc tokens with verified identity; two MCP doors; web sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentAgent RPCs now take an AgentIdentity (principal or anonymous) enrolled into the roster on first touch — the agent_tokens table, token hashing, mint/enroll/revoke-token RPCs are gone. VaporMcp gains two doors: /mcp (OAuth-gated, 401-challenges bare requests) and /mcp/anonymous (tokenless). The invite dialog becomes an Agents connection panel; a Sign in affordance and signed-in presence attribution land in the doc header. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 262 ++++---- agents/mcp-anonymous.ts | 65 -- agents/mcp-tools.ts | 78 ++- agents/mcp.ts | 125 ++-- app/components/AgentsPanel.tsx | 193 ++++++ app/components/InviteAgentDialog.tsx | 418 ------------- app/components/SignIn.tsx | 118 ++++ app/lib/agent-tokens.ts | 26 - app/lib/useYjsEditor.ts | 24 +- app/routes/doc.$id.agents.ts | 58 +- app/routes/doc.$id.tsx | 8 +- .../integration/agents/document-agent.test.ts | 572 +++++++++--------- tests/unit/agents/mcp-anonymous.test.ts | 131 ---- tests/unit/agents/mcp-tools.test.ts | 47 +- .../components/InviteAgentDialog.test.tsx | 213 ------- tests/unit/components/SignIn.test.tsx | 52 ++ tests/unit/lib/agent-tokens.test.ts | 14 - tests/unit/routes/doc-agents-route.test.ts | 85 +-- workers/app.ts | 43 +- workers/routes.ts | 2 +- 20 files changed, 987 insertions(+), 1547 deletions(-) delete mode 100644 agents/mcp-anonymous.ts create mode 100644 app/components/AgentsPanel.tsx delete mode 100644 app/components/InviteAgentDialog.tsx create mode 100644 app/components/SignIn.tsx delete mode 100644 app/lib/agent-tokens.ts delete mode 100644 tests/unit/agents/mcp-anonymous.test.ts delete mode 100644 tests/unit/components/InviteAgentDialog.test.tsx create mode 100644 tests/unit/components/SignIn.test.tsx delete mode 100644 tests/unit/lib/agent-tokens.test.ts diff --git a/agents/document.ts b/agents/document.ts index 672cc3eb..533e06ba 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -6,17 +6,15 @@ import * as awarenessProtocol from "y-protocols/awareness"; import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import { MSG_SYNC, MSG_AWARENESS, DOCUMENT_TTL_MS, DOC_FORMAT_VERSION, USER_COLOURS } from "../app/shared/constants"; -import type { AgentCapability, AgentRosterEntry, AgentError, Pace } from "../app/shared/agent-protocol"; +import type { AgentIdentity, AgentCapability, AgentRosterEntry, AgentError, Pace } from "../app/shared/agent-protocol"; import { AGENT_NAME_RE, - DEFAULT_CAPABILITIES, formatAnchor, findMentions, MAX_AGENTS_PER_DOC, RATE_LIMIT_MUTATIONS_PER_MIN, RATE_LIMIT_CHARS_PER_HOUR, } from "../app/shared/agent-protocol"; -import { generateAgentToken, hashToken } from "../app/lib/agent-tokens"; import { getBlocks, yDocToMarkdown, @@ -43,17 +41,6 @@ interface EventRow { /** How long an agent can go without a join/performance before its presence is auto-removed. */ const AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; -/** - * Upper bound on name-collision retries in enrollAnonymousAgent (base, - * base-2, base-3, …). Comfortably above MAX_AGENTS_PER_DOC so a full roster - * of same-named clients still gets a shot at every suffix before the cap - * itself (not exhausted attempts) is what stops enrollment. The `+ 8` is - * just headroom — a token or two may get revoked and re-minted with the - * same base name between attempts, so a margin past the cap avoids a - * spurious failure right at the boundary; it isn't tied to any other limit. - */ -const MAX_ANONYMOUS_NAME_ATTEMPTS = MAX_AGENTS_PER_DOC + 8; - /** * Durable Objects SQLite accepts Uint8Array for BLOB columns via the * template literal API, but the type signature expects string. This @@ -94,8 +81,8 @@ interface PerformanceRow { created_at: number; } -interface AgentTokenRow { - token_hash: string; +interface RosterRow { + identity_id: string; name: string; color: string; owner: string | null; @@ -112,7 +99,7 @@ interface MutationLogEntry { chars: number; } -function rowToRosterEntry(row: AgentTokenRow): AgentRosterEntry { +function rowToRosterEntry(row: RosterRow): AgentRosterEntry { return { name: row.name, color: row.color, @@ -176,8 +163,8 @@ class DocumentAgent extends Agent { ) `; this.sql` - CREATE TABLE IF NOT EXISTS agent_tokens ( - token_hash TEXT PRIMARY KEY, + CREATE TABLE IF NOT EXISTS roster ( + identity_id TEXT PRIMARY KEY, name TEXT UNIQUE, color TEXT, owner TEXT, @@ -442,10 +429,10 @@ class DocumentAgent extends Agent { override readonly alarm = async (): Promise => { // Auto-delete: remove all document data this.sql`DELETE FROM doc_state`; - // Revoke every minted agent token along with the document — a token - // must not stay valid against whatever content lands at this doc id - // if it's recreated after expiry. - this.sql`DELETE FROM agent_tokens`; + // The roster dies with the document — an enrollment must not persist + // against whatever content lands at this doc id if it's recreated + // after expiry. + this.sql`DELETE FROM roster`; // Any queued performances belong to a document that no longer exists. this.sql`DELETE FROM performances`; this.performanceQueue = []; @@ -585,116 +572,80 @@ class DocumentAgent extends Agent { } /** - * Mints a new agent token for this document, assigning it a slug name, - * a roster color (round-robin over USER_COLOURS), and a set of - * capabilities. Only the SHA-256 hash of the token is stored. + * Finds or creates this identity's roster entry. Rows are keyed by the + * verified identity id (a principal or an anonymous session id), so + * enrollment is idempotent per identity. The requested name gets a + * `-2`, `-3`, … suffix when a DIFFERENT identity already holds it. + * Capabilities on the row mirror the latest grant (they are display + * data — authorisation always checks the verified identity itself). */ - async mintAgentToken(opts: { - name: string; - owner?: string; - capabilities?: AgentCapability[]; - }): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }> { + private ensureRosterEntry( + identity: AgentIdentity, + ): { entry: AgentRosterEntry } | { error: AgentError } { this.ensureInitialised(); if (!this.docExists()) { return { error: { code: "doc_not_found", message: "Document does not exist" } }; } - if (!AGENT_NAME_RE.test(opts.name)) { - return { - error: { code: "invalid_name", message: `Invalid agent name: ${opts.name}` }, - }; - } - - const existing = this.sql<{ name: string }>` - SELECT name FROM agent_tokens WHERE name = ${opts.name} + const existing = this.sql` + SELECT * FROM roster WHERE identity_id = ${identity.id} `; if (existing.length > 0) { - return { - error: { code: "invalid_name", message: `Agent name already taken: ${opts.name}` }, - }; + const row = existing[0]; + const caps = JSON.stringify(identity.caps); + if (row.capabilities !== caps) { + this.sql`UPDATE roster SET capabilities = ${caps} WHERE identity_id = ${identity.id}`; + row.capabilities = caps; + } + return { entry: rowToRosterEntry(row) }; } - const roster = this.sql<{ name: string }>`SELECT name FROM agent_tokens`; - // A document is a public, unauthenticated URL: without a ceiling, anyone - // who can reach the invite endpoint can grow the roster without bound. + const roster = this.sql<{ name: string }>`SELECT name FROM roster`; + // A document is a public, unauthenticated URL: without a ceiling, + // anything that can reach it could grow the roster without bound. if (roster.length >= MAX_AGENTS_PER_DOC) { return { error: { code: "rate_limited", - message: `This document already has the maximum of ${MAX_AGENTS_PER_DOC} agents. Revoke one before inviting another.`, + message: `This document already has the maximum of ${MAX_AGENTS_PER_DOC} agents. Revoke one first.`, }, }; } - const color = USER_COLOURS[roster.length % USER_COLOURS.length].color; - const token = generateAgentToken(); - const tokenHash = await hashToken(token); - const capabilities = opts.capabilities ?? DEFAULT_CAPABILITIES; - const owner = opts.owner ?? null; - const createdAt = Date.now(); + const base = AGENT_NAME_RE.test(identity.name) ? identity.name : "agent"; + const taken = new Set(roster.map((r) => r.name)); + let name = base; + for (let n = 2; taken.has(name); n++) { + const suffix = `-${n}`; + name = base.length + suffix.length > 32 + ? `${base.slice(0, 32 - suffix.length).replace(/-+$/, "")}${suffix}` + : `${base}${suffix}`; + } + const color = USER_COLOURS[roster.length % USER_COLOURS.length].color; + const createdAt = Date.now(); this.sql` - INSERT INTO agent_tokens (token_hash, name, color, owner, capabilities, created_at, last_seen_at) - VALUES (${tokenHash}, ${opts.name}, ${color}, ${owner}, ${JSON.stringify(capabilities)}, ${createdAt}, ${null}) + INSERT INTO roster (identity_id, name, color, owner, capabilities, created_at, last_seen_at) + VALUES (${identity.id}, ${name}, ${color}, ${identity.owner}, ${JSON.stringify(identity.caps)}, ${createdAt}, ${null}) `; - return { - token, entry: { - name: opts.name, + name, color, - owner, - capabilities, + owner: identity.owner, + capabilities: identity.caps, createdAt, lastSeenAt: null, }, }; } - /** - * Mints an anonymous roster entry for a tokenless MCP session: - * DEFAULT_CAPABILITIES, owner null, name derived from `baseName` (already - * slugified by the caller). On a name collision, retries with `-2`, - * `-3`, … up to MAX_ANONYMOUS_NAME_ATTEMPTS. The MAX_AGENTS_PER_DOC cap - * still applies and is surfaced as-is (mintAgentToken's `rate_limited`). - */ - async enrollAnonymousAgent( - baseName: string, - ): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }> { - this.ensureInitialised(); - - const base = AGENT_NAME_RE.test(baseName) ? baseName : "agent"; - let lastError: AgentError = { - code: "rate_limited", - message: "Could not find an available anonymous agent name", - }; - - for (let attempt = 0; attempt < MAX_ANONYMOUS_NAME_ATTEMPTS; attempt++) { - const suffix = attempt === 0 ? "" : `-${attempt + 1}`; - let candidate = `${base}${suffix}`; - if (candidate.length > 32) { - candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/, "")}${suffix}`; - } - if (!AGENT_NAME_RE.test(candidate)) continue; - - const minted = await this.mintAgentToken({ name: candidate, capabilities: DEFAULT_CAPABILITIES }); - if (!("error" in minted)) return minted; - - lastError = minted.error; - // Only a name collision is worth retrying under a new suffix; the - // roster cap or a doc-existence failure won't clear up by renaming. - if (minted.error.code !== "invalid_name") return minted; - } - - return { error: lastError }; - } - /** Lists all agents minted for this document, oldest first. */ async getAgentRoster(): Promise { this.ensureInitialised(); - const rows = this.sql` - SELECT * FROM agent_tokens ORDER BY created_at ASC + const rows = this.sql` + SELECT * FROM roster ORDER BY created_at ASC `; return rows.map(rowToRosterEntry); } @@ -705,7 +656,7 @@ class DocumentAgent extends Agent { * body is itself fully synchronous SQL access). */ private getRosterNamesSync(): string[] { - const rows = this.sql<{ name: string }>`SELECT name FROM agent_tokens`; + const rows = this.sql<{ name: string }>`SELECT name FROM roster`; return rows.map((r) => r.name); } @@ -782,13 +733,13 @@ class DocumentAgent extends Agent { * Only a valid token is required — read is implied. */ async agentAwaitEvents( - token: string, + identity: AgentIdentity, args: { cursor?: number; timeoutMs?: number }, ): Promise< | { events: { seq: number; type: DocEventType; payload: unknown }[]; cursor: number } | { error: AgentError } > { - const verified = await this.verifyAgentToken(token); + const verified = await this.verifyIdentity(identity); if ("error" in verified) return verified; const cursor = args.cursor ?? 0; @@ -850,51 +801,51 @@ class DocumentAgent extends Agent { } /** Revokes an agent's token by name. Idempotent. */ - async revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }> { + async revokeAgentEntry(name: string): Promise<{ ok: true } | { error: AgentError }> { this.ensureInitialised(); - this.sql`DELETE FROM agent_tokens WHERE name = ${name}`; + this.sql`DELETE FROM roster WHERE name = ${name}`; + this.clearAgentIdleTimer(name); + this.setAgentPresence(name, null); return { ok: true }; } /** - * Verifies a presented agent token, optionally checking it carries a - * needed capability. `read` is implied by any valid token and is never - * stored in `capabilities`, so omit `needs` to check validity only. - * Updates `last_seen_at` on success. Used internally by every - * agent-facing RPC method. + * Validates a caller-supplied identity (already authenticated upstream by + * VaporMcp — DocumentAgent trusts its DO-RPC callers) and resolves it to + * this document's roster entry, enrolling on first touch. `read` is + * implied by any valid identity; pass `needs` to require a capability. + * Updates `last_seen_at` on success. */ - private async verifyAgentToken( - token: string, + private async verifyIdentity( + identity: AgentIdentity, needs?: AgentCapability, ): Promise<{ entry: AgentRosterEntry } | { error: AgentError }> { - this.ensureInitialised(); - - const tokenHash = await hashToken(token); - const rows = this.sql` - SELECT * FROM agent_tokens WHERE token_hash = ${tokenHash} - `; - if (rows.length === 0) { - return { error: { code: "invalid_token", message: "Invalid or unknown agent token" } }; + if ( + !identity || + (identity.kind !== "principal" && identity.kind !== "anonymous") || + typeof identity.id !== "string" || + identity.id.length === 0 || + typeof identity.name !== "string" || + !Array.isArray(identity.caps) + ) { + return { error: { code: "invalid_token", message: "Malformed agent identity" } }; } - const row = rows[0]; + const enrolled = this.ensureRosterEntry(identity); + if ("error" in enrolled) return enrolled; - // last_seen_at tracks presence, not authorisation: an agent that - // presented a valid token has been seen, whether or not the call it was - // making turns out to need a capability it lacks. Update before the - // capability check, so a denied call still keeps the agent in the - // presence list rather than making it look idle. + // last_seen_at tracks presence, not authorisation: update before the + // capability check so a denied call still counts as "seen". const now = Date.now(); - this.sql`UPDATE agent_tokens SET last_seen_at = ${now} WHERE token_hash = ${tokenHash}`; + this.sql`UPDATE roster SET last_seen_at = ${now} WHERE identity_id = ${identity.id}`; - const capabilities = JSON.parse(row.capabilities) as AgentCapability[]; - if (needs && !capabilities.includes(needs)) { + if (needs && !identity.caps.includes(needs)) { return { error: { code: "capability_denied", message: `Agent lacks capability: ${needs}` }, }; } - return { entry: rowToRosterEntry({ ...row, last_seen_at: now }) }; + return { entry: { ...enrolled.entry, lastSeenAt: now } }; } /** @@ -905,10 +856,9 @@ class DocumentAgent extends Agent { * hour. On success, records this attempt. The log is pruned to the last * hour on every check regardless of outcome. */ - private async checkRateLimit(token: string, chars: number): Promise<{ error: AgentError } | null> { - const tokenHash = await hashToken(token); + private async checkRateLimit(identityId: string, chars: number): Promise<{ error: AgentError } | null> { const rows = this.sql<{ recent_mutations: string | null }>` - SELECT recent_mutations FROM agent_tokens WHERE token_hash = ${tokenHash} + SELECT recent_mutations FROM roster WHERE identity_id = ${identityId} `; const now = Date.now(); @@ -934,12 +884,12 @@ class DocumentAgent extends Agent { const totalChars = pruned.reduce((sum, e) => sum + e.chars, 0); if (recentCount >= RATE_LIMIT_MUTATIONS_PER_MIN || totalChars + chars > RATE_LIMIT_CHARS_PER_HOUR) { - this.sql`UPDATE agent_tokens SET recent_mutations = ${JSON.stringify(pruned)} WHERE token_hash = ${tokenHash}`; + this.sql`UPDATE roster SET recent_mutations = ${JSON.stringify(pruned)} WHERE identity_id = ${identityId}`; return { error: { code: "rate_limited", message: "Agent mutation rate limit exceeded" } }; } pruned.push({ at: now, chars }); - this.sql`UPDATE agent_tokens SET recent_mutations = ${JSON.stringify(pruned)} WHERE token_hash = ${tokenHash}`; + this.sql`UPDATE roster SET recent_mutations = ${JSON.stringify(pruned)} WHERE identity_id = ${identityId}`; return null; } @@ -948,7 +898,7 @@ class DocumentAgent extends Agent { * presence (humans from awareness, agents from the roster), and comment * threads. Any valid token can read; no capability is required. */ - async agentRead(token: string): Promise< + async agentRead(identity: AgentIdentity): Promise< | { markdown: string; blocks: { anchor: string; text: string }[]; @@ -957,7 +907,7 @@ class DocumentAgent extends Agent { } | { error: AgentError } > { - const verified = await this.verifyAgentToken(token); + const verified = await this.verifyIdentity(identity); if ("error" in verified) return verified; const { doc, awareness } = this.ensureInitialised(); @@ -1018,10 +968,10 @@ class DocumentAgent extends Agent { * before or after it. Requires `write`. */ async agentInsert( - token: string, + identity: AgentIdentity, args: { anchor?: string; where: "before" | "after" | "append"; markdown: string; pace?: Pace }, ): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token, "write"); + const verified = await this.verifyIdentity(identity, "write"); if ("error" in verified) return verified; // Validate before charging the rate limit — a malformed call shouldn't @@ -1032,7 +982,7 @@ class DocumentAgent extends Agent { }; } - const rateLimited = await this.checkRateLimit(token, args.markdown.length); + const rateLimited = await this.checkRateLimit(identity.id, args.markdown.length); if (rateLimited) return rateLimited; return this.dispatchMutation(verified.entry.name, args.pace, { @@ -1048,10 +998,10 @@ class DocumentAgent extends Agent { * with new markdown, in one transaction. Requires `write`. */ async agentReplace( - token: string, + identity: AgentIdentity, args: { from: string; to?: string; markdown: string; pace?: Pace }, ): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token, "write"); + const verified = await this.verifyIdentity(identity, "write"); if ("error" in verified) return verified; // Validate before charging the rate limit — see agentInsert. @@ -1061,7 +1011,7 @@ class DocumentAgent extends Agent { }; } - const rateLimited = await this.checkRateLimit(token, args.markdown.length); + const rateLimited = await this.checkRateLimit(identity.id, args.markdown.length); if (rateLimited) return rateLimited; return this.dispatchMutation(verified.entry.name, args.pace, { @@ -1080,13 +1030,13 @@ class DocumentAgent extends Agent { * `suggest`. */ async agentSuggest( - token: string, + identity: AgentIdentity, args: { anchor: string; find: string; replacement: string; pace?: Pace }, ): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token, "suggest"); + const verified = await this.verifyIdentity(identity, "suggest"); if ("error" in verified) return verified; - const rateLimited = await this.checkRateLimit(token, args.find.length + args.replacement.length); + const rateLimited = await this.checkRateLimit(identity.id, args.find.length + args.replacement.length); if (rateLimited) return rateLimited; return this.dispatchMutation(verified.entry.name, args.pace, { @@ -1105,13 +1055,13 @@ class DocumentAgent extends Agent { * (app/lib/comment-threads.ts / useThreads.ts). Requires `comment`. */ async agentComment( - token: string, + identity: AgentIdentity, args: { anchor: string; quote?: string; text: string }, ): Promise<{ threadId: string } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token, "comment"); + const verified = await this.verifyIdentity(identity, "comment"); if ("error" in verified) return verified; - const rateLimited = await this.checkRateLimit(token, args.text.length); + const rateLimited = await this.checkRateLimit(identity.id, args.text.length); if (rateLimited) return rateLimited; const { doc } = this.ensureInitialised(); @@ -1144,13 +1094,13 @@ class DocumentAgent extends Agent { * thread returns `thread_not_found`. */ async agentReply( - token: string, + identity: AgentIdentity, args: { threadId: string; text: string }, ): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token, "comment"); + const verified = await this.verifyIdentity(identity, "comment"); if ("error" in verified) return verified; - const rateLimited = await this.checkRateLimit(token, args.text.length); + const rateLimited = await this.checkRateLimit(identity.id, args.text.length); if (rateLimited) return rateLimited; const { doc } = this.ensureInitialised(); @@ -1193,8 +1143,8 @@ class DocumentAgent extends Agent { * once it performs a mutation, as a caret) and (re)starts its 5-minute * idle timer. Any valid token may join — presence is not a capability. */ - async agentJoin(token: string, status?: string): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token); + async agentJoin(identity: AgentIdentity, status?: string): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity); if ("error" in verified) return verified; const { name, color } = verified.entry; @@ -1212,8 +1162,8 @@ class DocumentAgent extends Agent { * cancels its idle timer. The agent's token stays valid — leaving is * purely an awareness-visibility signal, not a revocation. */ - async agentLeave(token: string): Promise<{ ok: true } | { error: AgentError }> { - const verified = await this.verifyAgentToken(token); + async agentLeave(identity: AgentIdentity): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity); if ("error" in verified) return verified; this.clearAgentIdleTimer(verified.entry.name); @@ -1591,7 +1541,7 @@ class DocumentAgent extends Agent { let user = existing?.state?.user; if (!user) { const rows = this.sql<{ name: string; color: string }>` - SELECT name, color FROM agent_tokens WHERE name = ${agentName} + SELECT name, color FROM roster WHERE name = ${agentName} `; if (rows.length === 0) return; // unknown agent — nothing sane to show user = { name: rows[0].name, color: rows[0].color, isAgent: true }; diff --git a/agents/mcp-anonymous.ts b/agents/mcp-anonymous.ts deleted file mode 100644 index 3a4949a0..00000000 --- a/agents/mcp-anonymous.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Anonymous-mode tool wrapping: when an MCP session presents no bearer - * token, each document it touches gets an auto-enrolled anonymous agent - * instead of an `invalid_token` error. This module holds the pure decision - * logic (reuse a held token vs. enroll a new one) so it is unit-testable - * without the `agents` package; `agents/mcp.ts` supplies the real session - * state and DocumentAgent stub. - */ -import { isValidDocumentId } from "../app/shared/constants"; -import type { ToolDef, DocStub } from "./mcp-tools"; - -/** One doc's anonymous identity, held in the MCP session's persisted state. */ -export interface AnonymousAgentIdentity { - token: string; - name: string; -} - -/** Keyed by doc_id — one entry per document this session has touched. */ -export type AnonymousAgentState = Record; - -export interface RunAnonymousToolParams { - tool: ToolDef; - args: Record; - /** Resolves a document id to its DocumentAgent stub. */ - getStub(docId: string): Promise; - /** Slugified clientInfo.name (or "agent"), used only on first enrollment. */ - baseName: string; - /** The session's currently held { docId: identity } map. */ - state: AnonymousAgentState; - /** Persists a new state map after enrolling an agent for a new doc. */ - setState(next: AnonymousAgentState): void; -} - -/** - * Runs one tool call for a tokenless MCP session: reuses a held anonymous - * token for (session, doc_id) if one already exists, otherwise auto-enrolls - * a fresh one and persists it before running the tool. A malformed or - * missing doc_id is left for the tool's own validation to reject — this - * mirrors what an authenticated call does and avoids enrolling an agent for - * a document id that isn't even well-formed. - */ -export async function runAnonymousTool(params: RunAnonymousToolParams): Promise { - const { tool, args, getStub, baseName, state, setState } = params; - const docId = args.doc_id; - - if (typeof docId !== "string" || !isValidDocumentId(docId)) { - return tool.run({ getStub, token: "" }, args); - } - - const stub = await getStub(docId); - const resolvedStub = async () => stub; - - const held = state[docId]; - if (held) { - return tool.run({ getStub: resolvedStub, token: held.token }, args); - } - - const enrolled = await stub.enrollAnonymousAgent(baseName); - if ("error" in enrolled) { - return { error: enrolled.error }; - } - - setState({ ...state, [docId]: { token: enrolled.token, name: enrolled.entry.name } }); - return tool.run({ getStub: resolvedStub, token: enrolled.token }, args); -} diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index 13406d50..a1bd5f8e 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -4,38 +4,30 @@ * * This module deliberately imports nothing from the `agents` package (which * uses `cloudflare:` protocol imports) so it stays unit-testable in plain - * Vitest. `agents/mcp.ts` supplies the real stubs and bearer token. + * Vitest. `agents/mcp.ts` supplies the real stubs and verified identity. */ import { z } from "zod"; import { isValidDocumentId } from "../app/shared/constants"; -import { slugifyAgentName, type AgentError, type AgentRosterEntry } from "../app/shared/agent-protocol"; +import { slugifyAgentName, type AgentError, type AgentIdentity } from "../app/shared/agent-protocol"; /** The subset of the DocumentAgent RPC surface the tools call. */ export interface DocStub { - agentRead(token: string): Promise; - agentInsert(token: string, args: unknown): Promise; - agentReplace(token: string, args: unknown): Promise; - agentSuggest(token: string, args: unknown): Promise; - agentComment(token: string, args: unknown): Promise; - agentReply(token: string, args: unknown): Promise; - agentJoin(token: string, status?: string): Promise; - agentLeave(token: string): Promise; - agentAwaitEvents(token: string, args: unknown): Promise; - /** - * Mints an anonymous roster entry (DEFAULT_CAPABILITIES, owner null) for a - * tokenless MCP session, retrying with `-2`, `-3`, … on a name collision. - * Used only by the anonymous-mode wrapper in agents/mcp-anonymous.ts. - */ - enrollAnonymousAgent( - baseName: string, - ): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }>; + agentRead(identity: AgentIdentity): Promise; + agentInsert(identity: AgentIdentity, args: unknown): Promise; + agentReplace(identity: AgentIdentity, args: unknown): Promise; + agentSuggest(identity: AgentIdentity, args: unknown): Promise; + agentComment(identity: AgentIdentity, args: unknown): Promise; + agentReply(identity: AgentIdentity, args: unknown): Promise; + agentJoin(identity: AgentIdentity, status?: string): Promise; + agentLeave(identity: AgentIdentity): Promise; + agentAwaitEvents(identity: AgentIdentity, args: unknown): Promise; } export interface ToolDeps { /** Resolves a document id to its DocumentAgent stub. */ getStub(docId: string): Promise; - /** The bearer token presented on the MCP request. */ - token: string; + /** The verified identity of the caller (principal or anonymous session). */ + identity: AgentIdentity; } /** A zod raw shape, as `McpServer.registerTool` accepts for `inputSchema`. */ @@ -77,13 +69,11 @@ export function validateNewDocumentMarkdown( } /** - * The base agent name create_document mints its fresh token under, derived + * The base agent name create_document enrolls its creator under, derived * from the connecting MCP client's declared name — the same rule the - * anonymous tool path uses (agents/mcp-anonymous.ts) for the same reason: + * anonymous identity path uses for the same reason: * "agent" for every client made every doc's first collaborator look - * identical, with no way to tell which client created it. Since the - * document is brand new, there's no roster to collide with, so (unlike - * enrollAnonymousAgent) no retry-with-suffix loop is needed. Lives here + * identical, with no way to tell which client created it. Lives here * (rather than inline in agents/mcp.ts, which can't be imported in plain * Vitest) so the naming rule is unit-testable directly. */ @@ -106,7 +96,7 @@ function docTool(spec: { name: string; description: string; schema: ToolSchema; - call(stub: DocStub, token: string, args: Record): Promise; + call(stub: DocStub, identity: AgentIdentity, args: Record): Promise; }): ToolDef { return { name: spec.name, @@ -118,7 +108,7 @@ function docTool(spec: { return errorResult("doc_not_found", `Not a valid document id: ${String(id)}`); } const stub = await deps.getStub(id); - return spec.call(stub, deps.token, args); + return spec.call(stub, deps.identity, args); }, }; } @@ -129,7 +119,7 @@ export const TOOLS: ToolDef[] = [ description: "Read a vapor document: its full markdown, per-block anchors for editing, who is present, and open comment threads.", schema: {}, - call: (stub, token) => stub.agentRead(token), + call: (stub, identity) => stub.agentRead(identity), }), docTool({ @@ -142,8 +132,8 @@ export const TOOLS: ToolDef[] = [ markdown: z.string().describe("The markdown to insert."), pace, }, - call: (stub, token, args) => - stub.agentInsert(token, { + call: (stub, identity, args) => + stub.agentInsert(identity, { anchor: args.anchor as string | undefined, where: args.where as "before" | "after" | "append", markdown: args.markdown as string, @@ -164,8 +154,8 @@ export const TOOLS: ToolDef[] = [ markdown: z.string().describe("The markdown that replaces the range."), pace, }, - call: (stub, token, args) => - stub.agentReplace(token, { + call: (stub, identity, args) => + stub.agentReplace(identity, { from: args.from_anchor as string, to: args.to_anchor as string | undefined, markdown: args.markdown as string, @@ -183,8 +173,8 @@ export const TOOLS: ToolDef[] = [ replacement: z.string().describe("The suggested replacement text (empty string to delete)."), pace, }, - call: (stub, token, args) => - stub.agentSuggest(token, { + call: (stub, identity, args) => + stub.agentSuggest(identity, { anchor: args.anchor as string, find: args.find as string, replacement: args.replacement as string, @@ -201,8 +191,8 @@ export const TOOLS: ToolDef[] = [ quote: z.string().optional().describe("The text within the block the comment refers to."), text: z.string().describe("The comment body."), }, - call: (stub, token, args) => - stub.agentComment(token, { + call: (stub, identity, args) => + stub.agentComment(identity, { anchor: args.anchor as string, quote: args.quote as string | undefined, text: args.text as string, @@ -216,8 +206,8 @@ export const TOOLS: ToolDef[] = [ thread_id: z.string().describe("The thread id, as returned by comment or read_document."), text: z.string().describe("The reply body."), }, - call: (stub, token, args) => - stub.agentReply(token, { + call: (stub, identity, args) => + stub.agentReply(identity, { threadId: args.thread_id as string, text: args.text as string, }), @@ -230,14 +220,14 @@ export const TOOLS: ToolDef[] = [ schema: { status: z.string().optional().describe('A short activity string, e.g. "drafting intro".'), }, - call: (stub, token, args) => stub.agentJoin(token, args.status as string | undefined), + call: (stub, identity, args) => stub.agentJoin(identity, args.status as string | undefined), }), docTool({ name: "leave", - description: "Remove this agent's presence from the document. The token stays valid.", + description: "Remove this agent's presence from the document.", schema: {}, - call: (stub, token) => stub.agentLeave(token), + call: (stub, identity) => stub.agentLeave(identity), }), docTool({ @@ -254,9 +244,9 @@ export const TOOLS: ToolDef[] = [ .optional() .describe("How long to wait for an event, in seconds (max 50)."), }, - call: (stub, token, args) => { + call: (stub, identity, args) => { const timeoutS = args.timeout_s as number | undefined; - return stub.agentAwaitEvents(token, { + return stub.agentAwaitEvents(identity, { cursor: args.since_cursor as number | undefined, timeoutMs: timeoutS === undefined ? undefined : timeoutS * 1000, }); diff --git a/agents/mcp.ts b/agents/mcp.ts index 94b7f567..bd6a4481 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -1,14 +1,15 @@ /** - * The MCP server vapor exposes at /mcp. Each tool is backed by a - * `DocumentAgent` agent* RPC; the bearer token from the HTTP request arrives - * as `props.bearer` (set in workers/app.ts) and is passed straight through — - * the DocumentAgent is the only thing that validates it. + * The MCP server vapor exposes on two doors (routed in workers/app.ts): * - * A session with no bearer token isn't turned away: it operates in - * anonymous mode instead (see agents/mcp-anonymous.ts and the "Anonymous - * agents" section of docs/plans/2026-08-30-agent-collaborators-design.md). - * The per-(session, doc) identity it auto-enrolls lives in this DO's - * persisted `state`, so reconnects and replayed calls reuse it. + * /mcp — OAuth-authenticated. The worker verifies the access + * token (a vapor session JWT) and passes the claims in + * props.auth; bare requests get the 401 challenge that + * drives MCP clients into the consent flow. + * /mcp/anonymous — tokenless. props.auth is null and every call runs as + * a per-session anonymous identity (suggest + comment). + * + * Either way, each tool call is executed under an AgentIdentity that + * DocumentAgent enrolls into the document's roster on first touch. */ import { McpAgent } from "agents/mcp"; import { getAgentByName } from "agents"; @@ -20,14 +21,19 @@ import { createDocumentAgentName, type DocStub, } from "./mcp-tools"; -import { runAnonymousTool, type AnonymousAgentState } from "./mcp-anonymous"; import { generateDocumentId } from "../app/shared/constants"; -import { slugifyAgentName } from "../app/shared/agent-protocol"; +import { + slugifyAgentName, + DEFAULT_CAPABILITIES, + type AgentCapability, + type AgentIdentity, +} from "../app/shared/agent-protocol"; import { deserializeThreads } from "../app/lib/thread-serialization"; +import type Registry from "./registry"; export interface VaporMcpProps extends Record { - /** The Authorization: Bearer token, or null when none was presented. */ - bearer: string | null; + /** Verified OAuth claims (set by workers/app.ts), or null on the anonymous door. */ + auth: { principal: string; email: string; caps?: AgentCapability[] } | null; /** Origin of the MCP request, used to build document URLs. */ origin?: string; } @@ -39,11 +45,46 @@ function jsonContent(result: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; } -export class VaporMcp extends McpAgent { +export class VaporMcp extends McpAgent, VaporMcpProps> { server = new McpServer({ name: "vapor", version: "1.0.0" }); - /** Per-doc anonymous identities this session has auto-enrolled, keyed by doc_id. */ - initialState: AnonymousAgentState = {}; + /** Session-cached counterpart slug for the principal path. */ + private agentSlug: string | null = null; + + /** + * The identity every tool call runs under. Principals get their global + * counterpart slug from the Registry (cached per session); anonymous + * sessions get a stable per-session id and a clientInfo-derived name. + */ + private async identity(): Promise { + const auth = this.props?.auth ?? null; + if (auth) { + if (!this.agentSlug) { + const registry = (await getAgentByName(this.env.Registry, "global")) as unknown as Registry; + const ensured = await registry.ensureAgentSlug(auth.principal); + this.agentSlug = + "slug" in ensured ? ensured.slug : slugifyAgentName(auth.email.split("@")[0] ?? "agent"); + } + return { + kind: "principal", + id: auth.principal, + name: this.agentSlug, + owner: auth.principal, + caps: auth.caps ?? [...DEFAULT_CAPABILITIES], + }; + } + + const clientInfo = this.server.server.getClientVersion(); + return { + kind: "anonymous", + // this.name is the per-session DO instance name (stable across + // reconnects of the same MCP session). + id: `anon:${this.name}`, + name: slugifyAgentName(clientInfo?.name ?? "agent"), + owner: null, + caps: [...DEFAULT_CAPABILITIES], + }; + } async init() { const getStub = (docId: string) => @@ -54,38 +95,20 @@ export class VaporMcp extends McpAgent tool.name, { description: tool.description, inputSchema: tool.schema }, async (args: Record) => { - const token = this.props?.bearer ?? null; - - if (token) { - const result = await tool.run({ getStub, token }, args); - return jsonContent(result); - } - - // No bearer: run in anonymous mode, auto-enrolling (and reusing) - // an agent identity per document for the lifetime of this - // session. The client's declared name seeds the agent's name. - const clientInfo = this.server.server.getClientVersion(); - const baseName = slugifyAgentName(clientInfo?.name ?? "agent"); - const result = await runAnonymousTool({ - tool, - args, - getStub, - baseName, - state: this.state, - setState: (next) => this.setState(next), - }); + const identity = await this.identity(); + const result = await tool.run({ getStub, identity }, args); return jsonContent(result); }, ); } - // create_document needs env and no token, so it lives here rather than in - // the (deliberately dependency-free) tool table. + // create_document needs env access, so it lives here rather than in the + // (deliberately dependency-free) tool table. this.server.registerTool( "create_document", { description: - "Create a new vapor document, optionally with starting markdown. Returns its id, URL, and a fresh agent token for it (suggest + comment capabilities).", + "Create a new vapor document, optionally with starting markdown. Returns its id and URL; the calling identity is enrolled as the document's first agent.", inputSchema: { markdown: z.string().optional().describe("Optional starting markdown for the document."), }, @@ -113,22 +136,18 @@ export class VaporMcp extends McpAgent }); } - // Same clientInfo-derived naming as the anonymous tool path, for the - // same reason: the doc is brand new, so there's no roster to - // collide with and no retry loop is needed. - const clientInfo = this.server.server.getClientVersion(); - const minted = await stub.mintAgentToken({ name: createDocumentAgentName(clientInfo?.name) }); - if ("error" in minted) return jsonContent(minted); - - // create_document is tokenless for everyone, but an anonymous - // session should keep using this same minted token for follow-up - // tool calls on the new doc rather than enrolling a second agent. - if (!this.props?.bearer) { - this.setState({ ...this.state, [id]: { token: minted.token, name: minted.entry.name } }); - } + // Enroll the creator on the fresh doc so it appears in the roster + // immediately. Anonymous identities keep their session name; for a + // brand-new doc there is nothing to collide with. + const identity = await this.identity(); + const creatorName = + identity.kind === "anonymous" + ? createDocumentAgentName(this.server.server.getClientVersion()?.name) + : identity.name; + await (stub as unknown as DocStub).agentJoin({ ...identity, name: creatorName }); const origin = this.props?.origin ?? DEFAULT_ORIGIN; - return jsonContent({ id, url: `${origin}/${id}`, token: minted.token }); + return jsonContent({ id, url: `${origin}/${id}` }); }, ); } diff --git a/app/components/AgentsPanel.tsx b/app/components/AgentsPanel.tsx new file mode 100644 index 00000000..d2558c69 --- /dev/null +++ b/app/components/AgentsPanel.tsx @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useParams } from "react-router"; +import type { AgentRosterEntry } from "~/shared/agent-protocol"; + +function relativeTime(ts: number | null): string { + if (ts == null) return "never"; + const diffMs = Date.now() - ts; + if (diffMs < 60_000) return "just now"; + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function SnippetRow({ label, text }: { label: string; text: string }) { + const [copied, setCopied] = useState(false); + const copy = useCallback(() => { + navigator.clipboard?.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, + () => {}, + ); + }, [text]); + return ( +
+
+ {label} + +
+ + {text} + +
+ ); +} + +/** + * The Agents panel: how to connect an agent over MCP (the two doors) plus + * the document's live roster with per-entry revoke. Token minting is gone — + * agents authenticate via OAuth (or the anonymous door) and enroll on first + * touch. + */ +export default function AgentsPanel() { + const params = useParams(); + const docId = params.id ?? ""; + const [open, setOpen] = useState(false); + const [roster, setRoster] = useState([]); + const titleId = useId(); + const triggerRef = useRef(null); + const origin = typeof window !== "undefined" ? window.location.origin : "https://vapor.fyi"; + + const loadRoster = useCallback(() => { + fetch(`/${docId}/agents`) + .then((r) => (r.ok ? r.json() : [])) + .then((data) => setRoster(Array.isArray(data) ? data : [])) + .catch(() => {}); + }, [docId]); + + useEffect(() => { + if (open) loadRoster(); + }, [open, loadRoster]); + + const handleClose = useCallback(() => { + setOpen(false); + triggerRef.current?.focus(); + }, []); + + useEffect(() => { + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") handleClose(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, handleClose]); + + async function handleRevoke(name: string) { + await fetch(`/${docId}/agents`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ intent: "revoke", name }), + }); + loadRoster(); + } + + function handleOverlayClick(e: React.MouseEvent) { + if (e.target === e.currentTarget) handleClose(); + } + + const claudeCodeCommand = `claude mcp add --transport http vapor ${origin}/mcp`; + const anonCommand = `claude mcp add --transport http vapor ${origin}/mcp/anonymous`; + + return ( + <> + + {open && ( +
+
+
+

+ Agents +

+ +
+ +
+

+ Connect an AI agent over MCP. Signing in gives it a stable identity and, + if you grant it, write access; the anonymous door needs no account and can + suggest and comment. +

+ + +

+ For claude.ai, add {origin}/mcp as a custom + connector (Settings → Connectors). +

+ +
+

+ In this document +

+ {roster.length === 0 ? ( +

No agents yet.

+ ) : ( +
    + {roster.map((entry) => ( +
  • + + + {entry.name} + + {entry.capabilities.map((c) => ( + + {c} + + ))} + + {entry.owner && ( + {entry.owner} + )} + {relativeTime(entry.lastSeenAt)} + + +
  • + ))} +
+ )} +
+
+
+
+ )} + + ); +} diff --git a/app/components/InviteAgentDialog.tsx b/app/components/InviteAgentDialog.tsx deleted file mode 100644 index 6d5c1326..00000000 --- a/app/components/InviteAgentDialog.tsx +++ /dev/null @@ -1,418 +0,0 @@ -import { useState, useEffect, useCallback, useId, useRef } from "react"; -import * as Switch from "@radix-ui/react-switch"; -import { useDocument } from "~/lib/DocumentContext"; -import { - AGENT_NAME_RE, - DEFAULT_CAPABILITIES, - type AgentCapability, - type AgentRosterEntry, -} from "~/shared/agent-protocol"; - -const CAPABILITY_ORDER: AgentCapability[] = ["suggest", "comment", "write"]; -const CAPABILITY_LABELS: Record = { - suggest: "Suggest", - comment: "Comment", - write: "Write", -}; - -const NAME_SUGGESTIONS = [ - "scribe", - "muse", - "echo", - "quill", - "sage", - "nova", - "atlas", - "juniper", - "orbit", - "flux", -]; - -function pickUnusedName(taken: Set): string { - for (const candidate of NAME_SUGGESTIONS) { - if (!taken.has(candidate)) return candidate; - } - return `agent-${Math.random().toString(36).slice(2, 6)}`; -} - -function relativeTime(ts: number | null): string { - if (ts == null) return "never"; - const diffMs = Date.now() - ts; - if (diffMs < 60_000) return "just now"; - const mins = Math.floor(diffMs / 60_000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -interface MintedAgent { - token: string; - entry: AgentRosterEntry; -} - -interface ErrorBody { - error: { message: string }; -} - -function switchClass() { - return "inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-border transition-colors data-[state=checked]:bg-coral"; -} - -function thumbClass() { - return "pointer-events-none block h-5 w-5 rounded-full bg-paper shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"; -} - -export default function InviteAgentDialog() { - const { docId } = useDocument(); - const [open, setOpen] = useState(false); - const [roster, setRoster] = useState([]); - const [name, setName] = useState(""); - const [owner, setOwner] = useState(""); - const [capabilities, setCapabilities] = useState>( - () => new Set(DEFAULT_CAPABILITIES), - ); - const [nameError, setNameError] = useState(null); - const [minted, setMinted] = useState(null); - const [copiedField, setCopiedField] = useState(null); - const nameInputId = useId(); - const ownerInputId = useId(); - const titleId = useId(); - const triggerRef = useRef(null); - const nameInputRef = useRef(null); - - const loadRoster = useCallback(async () => { - const res = await fetch(`/${docId}/agents`); - if (!res.ok) return; - const list = (await res.json()) as AgentRosterEntry[]; - setRoster(list); - }, [docId]); - - // Fetches the roster from the server when the dialog opens; the setState - // happens after the await, not synchronously in the effect body. - useEffect(() => { - if (!open) return; - void loadRoster(); - }, [open, loadRoster]); - - // Derives the pre-filled name suggestion from the freshly loaded roster; - // only runs once per dialog open (guarded by the `current` check). - useEffect(() => { - if (!open || minted) return; - const taken = new Set(roster.map((r) => r.name)); - setName((current) => (current ? current : pickUnusedName(taken))); - }, [open, roster, minted]); - - const handleOpen = useCallback(() => { - setMinted(null); - setName(""); - setOwner(""); - setNameError(null); - setCopiedField(null); - setCapabilities(new Set(DEFAULT_CAPABILITIES)); - setOpen(true); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - // Return focus to the menu item that opened the dialog. - triggerRef.current?.focus(); - }, []); - - // Focuses the name input as soon as the dialog (in its default, unminted - // form) mounts, so keyboard users land somewhere useful instead of on the - // document body. - useEffect(() => { - if (!open || minted) return; - nameInputRef.current?.focus(); - // Only on the open transition — refocusing on every keystroke re-render - // would fight the user. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - - // Escape closes the dialog, same as the overlay-click / close-button paths. - useEffect(() => { - if (!open) return; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") handleClose(); - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [open, handleClose]); - - // Closes only on a genuine backdrop click — a click that bubbles up from - // the panel itself has `e.target` set to the descendant it started on, not - // the overlay, so it's ignored here. - function handleOverlayClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) handleClose(); - } - - function toggleCapability(cap: AgentCapability) { - setCapabilities((prev) => { - const next = new Set(prev); - if (next.has(cap)) next.delete(cap); - else next.add(cap); - return next; - }); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!AGENT_NAME_RE.test(name)) { - setNameError("Lowercase letters, digits, and hyphens"); - return; - } - setNameError(null); - - const res = await fetch(`/${docId}/agents`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - intent: "mint", - name, - owner: owner.trim() || undefined, - capabilities: [...capabilities], - }), - }); - const json = (await res.json()) as MintedAgent | ErrorBody; - if (!res.ok || "error" in json) { - setNameError("error" in json ? json.error.message : "Failed to create agent"); - return; - } - setMinted(json); - void loadRoster(); - } - - async function handleRevoke(revokeName: string) { - await fetch(`/${docId}/agents`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ intent: "revoke", name: revokeName }), - }); - void loadRoster(); - } - - async function handleCopy(field: string, text: string) { - await navigator.clipboard.writeText(text); - setCopiedField(field); - setTimeout(() => setCopiedField((f) => (f === field ? null : f)), 2000); - } - - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const claudeCodeCommand = minted - ? `claude mcp add --transport http vapor ${origin}/mcp --header "Authorization: Bearer ${minted.token}"` - : ""; - const connectorUrl = `${origin}/mcp`; - const mcpServersJson = minted - ? JSON.stringify( - { - mcpServers: { - vapor: { - url: `${origin}/mcp`, - headers: { Authorization: `Bearer ${minted.token}` }, - }, - }, - }, - null, - 2, - ) - : ""; - - return ( - <> - - {open && ( -
-
-
-

- Invite agent -

- -
- - {!minted ? ( -
-
- - setName(e.target.value)} - className="w-full border border-border bg-paper px-3 py-1.5 font-mono text-sm outline-none focus:border-ink" - /> - {nameError &&

{nameError}

} -
-
- - setOwner(e.target.value)} - className="w-full border border-border bg-paper px-3 py-1.5 text-sm outline-none focus:border-ink" - /> -
-
- {CAPABILITY_ORDER.map((cap) => ( -
- {CAPABILITY_LABELS[cap]} - toggleCapability(cap)} - aria-label={CAPABILITY_LABELS[cap]} - className={switchClass()} - > - - -
- ))} -
- -
- ) : ( -
- - {minted.token} - - -

- This token is shown once. Revoke and re-mint to replace it. -

-
- - - -
-
- )} - -
-

Roster

- {roster.length === 0 ? ( -

No agents invited yet.

- ) : ( -
    - {roster.map((entry) => ( -
  • - - - {entry.name} - {entry.capabilities.map((c) => ( - - {c} - - ))} - {entry.owner && {entry.owner}} - {relativeTime(entry.lastSeenAt)} - - -
  • - ))} -
- )} -
-
-
- )} - - ); -} - -function SnippetRow({ - label, - text, - field, - copiedField, - onCopy, -}: { - label: string; - text: string; - field: string; - copiedField: string | null; - onCopy: (field: string, text: string) => void; -}) { - return ( -
-
- {label} - -
-
-        {text}
-      
-
- ); -} diff --git a/app/components/SignIn.tsx b/app/components/SignIn.tsx new file mode 100644 index 00000000..fb2ca8b7 --- /dev/null +++ b/app/components/SignIn.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface Session { + signedIn: boolean; + displayName?: string; +} + +declare global { + interface Window { + google?: { + accounts: { + id: { + initialize: (opts: { client_id: string; callback: (r: { credential: string }) => void }) => void; + renderButton: (el: HTMLElement, opts: Record) => void; + }; + }; + }; + } +} + +/** + * Header sign-in affordance. Signed out: a "Sign in" button that opens a + * popover and loads Google Identity Services on demand (never on every doc + * view). Signed in: the display name plus sign-out. Optional everywhere — + * anonymous users never see more than the button. + */ +export default function SignIn() { + const [session, setSession] = useState(null); + const [open, setOpen] = useState(false); + const buttonHost = useRef(null); + + const refresh = useCallback(() => { + fetch("/auth/me") + .then((r) => r.json()) + .then((s) => setSession(s as Session)) + .catch(() => setSession({ signedIn: false })); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + // Load GSI and render the Google button only when the popover opens. + useEffect(() => { + if (!open || !buttonHost.current) return; + let cancelled = false; + + async function mount() { + const config = (await fetch("/auth/config").then((r) => r.json())) as { googleClientId?: string }; + if (cancelled || !config.googleClientId) return; + + const render = () => { + if (cancelled || !window.google || !buttonHost.current) return; + window.google.accounts.id.initialize({ + client_id: config.googleClientId as string, + callback: async (r) => { + const res = await fetch("/auth/google", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credential: r.credential }), + }); + if (res.ok) { + setOpen(false); + refresh(); + } + }, + }); + window.google.accounts.id.renderButton(buttonHost.current, { theme: "outline" }); + }; + + if (window.google) { + render(); + } else { + const s = document.createElement("script"); + s.src = "https://accounts.google.com/gsi/client"; + s.async = true; + s.onload = render; + document.head.appendChild(s); + } + } + mount(); + return () => { + cancelled = true; + }; + }, [open, refresh]); + + async function signOut() { + await fetch("/auth/logout", { method: "POST" }); + refresh(); + } + + if (session?.signedIn) { + return ( +
+ {session.displayName} + +
+ ); + } + + return ( +
+ + {open && ( +
+
+
+ )} +
+ ); +} diff --git a/app/lib/agent-tokens.ts b/app/lib/agent-tokens.ts deleted file mode 100644 index da82160e..00000000 --- a/app/lib/agent-tokens.ts +++ /dev/null @@ -1,26 +0,0 @@ -const TOKEN_PREFIX = "vpr_"; -const TOKEN_RANDOM_BYTES = 32; - -function toBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -/** Generates a new agent token: "vpr_" + 43 base64url chars (32 random bytes). */ -export function generateAgentToken(): string { - const bytes = new Uint8Array(TOKEN_RANDOM_BYTES); - crypto.getRandomValues(bytes); - return TOKEN_PREFIX + toBase64Url(bytes); -} - -/** Hashes a token to a stable 64-char hex SHA-256 digest for storage/lookup. */ -export async function hashToken(token: string): Promise { - const data = new TextEncoder().encode(token); - const digest = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} diff --git a/app/lib/useYjsEditor.ts b/app/lib/useYjsEditor.ts index 202070a3..f1cf3015 100644 --- a/app/lib/useYjsEditor.ts +++ b/app/lib/useYjsEditor.ts @@ -22,7 +22,29 @@ function anonUserInfo(): UserInfo { export function useYjsEditor(docId: string) { const doc = useMemo(() => new Y.Doc(), []); const awareness = useMemo(() => new Awareness(doc), [doc]); - const user = useMemo(() => anonUserInfo(), []); + const [user, setUser] = useState(anonUserInfo); + + // If the viewer is signed in, present their real name instead of the + // anonymous animal. Sign-in is optional; anonymous users keep the animal. + useEffect(() => { + let cancelled = false; + fetch("/auth/me") + .then((r) => r.json()) + .then((raw) => { + const s = raw as { signedIn?: boolean; displayName?: string; principal?: string }; + if (cancelled || !s.signedIn || !s.displayName) return; + setUser((prev) => ({ + ...prev, + name: s.displayName as string, + id: s.principal ?? prev.id, + animal: undefined, + })); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); const docState = useMemo(() => doc.getMap("docState"), [doc]); const providerRef = useRef(null); const [synced, setSynced] = useState(false); diff --git a/app/routes/doc.$id.agents.ts b/app/routes/doc.$id.agents.ts index 00bd8cb2..cc0943b1 100644 --- a/app/routes/doc.$id.agents.ts +++ b/app/routes/doc.$id.agents.ts @@ -3,7 +3,6 @@ import type { Route } from "./+types/doc.$id.agents"; import { isValidDocumentId } from "~/shared/constants"; import { getCloudflare } from "~/lib/cloudflare.server"; import type { - AgentCapability, AgentError, AgentErrorCode, AgentRosterEntry, @@ -11,17 +10,10 @@ import type { /** The subset of the DocumentAgent RPC surface this route calls. */ interface AgentStub { - mintAgentToken(opts: { - name: string; - owner?: string; - capabilities?: AgentCapability[]; - }): Promise<{ token: string; entry: AgentRosterEntry } | { error: AgentError }>; getAgentRoster(): Promise; - revokeAgentToken(name: string): Promise<{ ok: true } | { error: AgentError }>; + revokeAgentEntry(name: string): Promise<{ ok: true } | { error: AgentError }>; } -const KNOWN_CAPABILITIES: AgentCapability[] = ["comment", "suggest", "write"]; - function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, @@ -38,11 +30,9 @@ function badRequest(message: string) { } /** - * Maps a DocumentAgent `AgentError` onto an HTTP status. Only - * `mintAgentToken`/`revokeAgentToken` errors reach this route today - * (doc_not_found, invalid_name), but the mapping covers the full - * `AgentErrorCode` union so a future RPC error doesn't fall through - * unmapped. + * Maps a DocumentAgent `AgentError` onto an HTTP status. The mapping + * covers the full `AgentErrorCode` union so a future RPC error doesn't + * fall through unmapped. */ function statusForErrorCode(code: AgentErrorCode): number { switch (code) { @@ -102,38 +92,12 @@ export async function action({ params, context, request }: Route.ActionArgs) { const record = body as Record; if (record.intent === "mint") { - const name = record.name; - if (typeof name !== "string") { - return badRequest("name is required"); - } - - const ownerRaw = record.owner; - const owner = - typeof ownerRaw === "string" && ownerRaw.trim() ? ownerRaw : undefined; - - let capabilities: AgentCapability[] | undefined; - if (record.capabilities !== undefined) { - const capsRaw = record.capabilities; - const isValid = - Array.isArray(capsRaw) && - capsRaw.every( - (c): c is AgentCapability => - typeof c === "string" && - KNOWN_CAPABILITIES.includes(c as AgentCapability), - ); - if (!isValid) { - return badRequest( - `capabilities must be a subset of ${KNOWN_CAPABILITIES.join(", ")}`, - ); - } - capabilities = capsRaw as AgentCapability[]; - } - - const result = await stub.mintAgentToken({ name, owner, capabilities }); - if ("error" in result) { - return jsonResponse(result, statusForErrorCode(result.error.code)); - } - return jsonResponse(result, 201); + // Per-doc tokens retired with the identity phase: agents connect over + // MCP (OAuth or the anonymous door) and enroll on first touch. + return jsonResponse( + { error: { message: "Token minting is gone. Connect via https://vapor.fyi/mcp instead." } }, + 410, + ); } if (record.intent === "revoke") { @@ -142,7 +106,7 @@ export async function action({ params, context, request }: Route.ActionArgs) { return badRequest("name is required"); } - const result = await stub.revokeAgentToken(name); + const result = await stub.revokeAgentEntry(name); if ("error" in result) { return jsonResponse(result, statusForErrorCode(result.error.code)); } diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index 55332762..efd7c452 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -11,13 +11,14 @@ import Preview from "~/components/Preview"; import PreviewToggle from "~/components/PreviewToggle"; import ConnectionStatus from "~/components/ConnectionStatus"; import ShareButton from "~/components/ShareButton"; -import InviteAgentDialog from "~/components/InviteAgentDialog"; +import AgentsPanel from "~/components/AgentsPanel"; import ModeToggle from "~/components/ModeToggle"; import CleanViewToggle from "~/components/CleanViewToggle"; import SuggestionActions from "~/components/SuggestionActions"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; import ThemeSelector from "~/components/ThemeSelector"; +import SignIn from "~/components/SignIn"; import MobilePanel from "~/components/MobilePanel"; import OnboardingBanner from "~/components/OnboardingBanner"; @@ -112,7 +113,10 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul
- + +
+
+
diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index d2203091..7ea99090 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -15,7 +15,7 @@ import * as Y from "yjs"; import * as awarenessProtocol from "y-protocols/awareness"; import { DOCUMENT_TTL_MS, DOC_FORMAT_VERSION } from "~/shared/constants"; import { YjsProvider } from "~/lib/yjs-provider"; -import { MAX_AGENTS_PER_DOC, type AgentCapability } from "~/shared/agent-protocol"; +import { MAX_AGENTS_PER_DOC, type AgentCapability, type AgentIdentity } from "~/shared/agent-protocol"; import { yDocToMarkdown } from "~/lib/y-markdown"; /* ------------------------------------------------------------------ */ @@ -27,7 +27,7 @@ let mockConnectionMap: Map; let mockSetAlarm: ReturnType; /** * Generic in-memory table store for tables other than `doc_state` - * (currently `agent_tokens`; later tasks add `performances`/`events`). + * (currently `roster`, `performances`, `events`). * Keyed by table name -> array of row objects. Query-shaped, not a real * SQL engine: it pattern-matches the exact INSERT/SELECT/UPDATE/DELETE * forms the DO code uses, mirroring the `doc_state` fake above. @@ -127,7 +127,7 @@ vi.mock("agents", () => ({ // and the DO code actually supplies values for. const constrained: Record = { performances: "id", - agent_tokens: "name", + roster: "name", }; const uniqueCol = constrained[table]; if (uniqueCol && rows.some((r) => r[uniqueCol] === row[uniqueCol])) { @@ -299,6 +299,25 @@ describe("DocumentAgent", () => { return conn; } + /** + * Builds a verified `AgentIdentity` to pass as the first argument to any + * agent RPC. Enrollment is implicit — the first RPC call for a given + * `id` creates its roster row (name from `name`, owner from `owner`, + * capabilities from `caps`). Distinct `id` values are what separate + * roster entries; give collaborating "agents" in the same test distinct + * ids even when they share a display `name`. + */ + function identity(over: Partial = {}): AgentIdentity { + return { + kind: "principal", + id: "email:a@x.com", + name: "scribe", + owner: "email:a@x.com", + caps: ["suggest", "comment", "write"], + ...over, + }; + } + /** * Create a new DocumentAgent backed by its own fresh, isolated SQL store * — simulating a distinct document (distinct Durable Object instance) @@ -363,16 +382,15 @@ describe("DocumentAgent", () => { /** * Waits for a call under test to register its (faked) setTimeout before - * vi.advanceTimersByTimeAsync() runs. agentAwaitEvents does real, - * un-faked async work (crypto.subtle.digest inside verifyAgentToken) - * *before* parking on a setTimeout — advancing fake time too early would - * race ahead of that registration and hang forever, since no further - * real time ever passes to let the crypto step catch up. Polls - * vi.getTimerCount() via real (un-faked) setImmediate ticks rather than - * a fixed number of flushes, so it's robust regardless of how many real - * event-loop turns the crypto call actually needs (which varies under - * system load) — capped so a genuine bug still fails fast instead of - * hanging. + * vi.advanceTimersByTimeAsync() runs. agentAwaitEvents awaits + * verifyIdentity (and its own readPast query) before parking on a + * setTimeout — advancing fake time too early would race ahead of that + * registration and hang forever, since no further real time ever passes + * to let the pending microtasks catch up. Polls vi.getTimerCount() via + * real (un-faked) setImmediate ticks rather than a fixed number of + * flushes, so it's robust regardless of how many real event-loop turns + * that chain actually needs (which varies under system load) — capped + * so a genuine bug still fails fast instead of hanging. */ async function waitForTimerRegistered(): Promise { for (let i = 0; i < 200 && vi.getTimerCount() === 0; i++) { @@ -692,13 +710,12 @@ describe("DocumentAgent", () => { body: JSON.stringify({ content: "# Title\n\nBody." }), }), ); - const minted = await agent.mintAgentToken({ name: "scribe", capabilities: ["write"] }); - const token = (minted as { token: string }).token; + const id = identity({ caps: ["write"] }); const client = connectYjsClient(agent); expect(yDocToMarkdown(client.doc)).toBe("# Title\n\nBody."); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: "Agent wrote this.", pace: "instant", @@ -721,15 +738,14 @@ describe("DocumentAgent", () => { body: JSON.stringify({ content: "# Title\n\nBody." }), }), ); - const minted = await agent.mintAgentToken({ name: "scribe", capabilities: ["write"] }); - const token = (minted as { token: string }).token; + const id = identity({ caps: ["write"] }); const client = connectYjsClient(agent); // A connected human is required for a non-instant pace to queue // rather than apply immediately. createConnection(); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: "Typed live to the client.", pace: "natural", @@ -778,90 +794,106 @@ describe("DocumentAgent", () => { }); /* ================================================================ */ - /* Agent token roster */ + /* Agent identity roster (implicit enrollment) */ /* ================================================================ */ describe("agent roster", () => { - /** verifyAgentToken is private on DocumentAgent; cast to call it from tests. */ - function asVerifier(a: InstanceType) { - return a as unknown as { - verifyAgentToken( - token: string, - needs?: string, - ): Promise<{ entry: unknown } | { error: { code: string } }>; - }; - } - - it("mints, lists, verifies capability, revokes", async () => { + it("enrolls a new identity on its first RPC call and lists it", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - const minted = await agent.mintAgentToken({ name: "scribe" }); - expect("token" in minted && minted.token).toMatch(/^vpr_/); + const id = identity({ caps: ["suggest", "comment"] }); + expect(await agent.agentJoin(id)).toEqual({ ok: true }); expect((await agent.getAgentRoster())[0]).toMatchObject({ name: "scribe", capabilities: ["suggest", "comment"], }); // Default grant lacks write. - const v = await asVerifier(agent).verifyAgentToken( - (minted as { token: string }).token, - "write", - ); - expect(v).toMatchObject({ error: { code: "capability_denied" } }); + const denied = await agent.agentInsert(id, { where: "append", markdown: "x" }); + expect(denied).toMatchObject({ error: { code: "capability_denied" } }); - await agent.revokeAgentToken("scribe"); + await agent.revokeAgentEntry("scribe"); expect(await agent.getAgentRoster()).toHaveLength(0); }); - it("rejects bad names and duplicates", async () => { + it("enrolls distinct identities into distinct roster rows, oldest first", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - expect(await agent.mintAgentToken({ name: "Bad Name" })).toMatchObject({ - error: { code: "invalid_name" }, - }); + for (let i = 0; i < 3; i++) { + await agent.agentJoin(identity({ id: `email:agent-${i}@x.com`, name: `agent-${i}` })); + } - await agent.mintAgentToken({ name: "scribe" }); - expect(await agent.mintAgentToken({ name: "scribe" })).toMatchObject({ - error: { code: "invalid_name" }, - }); + const roster = await agent.getAgentRoster(); + expect(roster.map((r) => r.name)).toEqual(["agent-0", "agent-1", "agent-2"]); + }); + + it("suffixes a name collision from a different identity id with -2, -3, …", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + + await agent.agentJoin(identity({ id: "email:a@x.com", name: "claude-code" })); + const second = await agent.agentJoin(identity({ id: "email:b@x.com", name: "claude-code" })); + const third = await agent.agentJoin(identity({ id: "email:c@x.com", name: "claude-code" })); + expect(second).toEqual({ ok: true }); + expect(third).toEqual({ ok: true }); + + const roster = await agent.getAgentRoster(); + expect(roster.map((r) => r.name)).toEqual(["claude-code", "claude-code-2", "claude-code-3"]); + }); + + it("reuses the same roster row on repeat calls from the same identity id", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + + const id = identity({ id: "email:a@x.com", name: "claude-code" }); + await agent.agentJoin(id); + await agent.agentJoin(id); + + expect(await agent.getAgentRoster()).toHaveLength(1); + }); + + it("falls back to the 'agent' base name when the identity name fails AGENT_NAME_RE", async () => { + await agent.onRequest(new Request("https://do/", { method: "POST" })); + + await agent.agentJoin(identity({ name: "Bad Name" })); + expect((await agent.getAgentRoster())[0].name).toBe("agent"); }); it("caps the roster at MAX_AGENTS_PER_DOC", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); for (let i = 0; i < MAX_AGENTS_PER_DOC; i++) { - expect(await agent.mintAgentToken({ name: `agent-${i}` })).toHaveProperty("token"); + const id = identity({ id: `email:agent-${i}@x.com`, name: `agent-${i}` }); + expect(await agent.agentJoin(id)).toEqual({ ok: true }); } - expect(await agent.mintAgentToken({ name: "one-too-many" })).toMatchObject({ + const oneTooMany = identity({ id: "email:one-too-many@x.com", name: "one-too-many" }); + expect(await agent.agentJoin(oneTooMany)).toMatchObject({ error: { code: "rate_limited", message: expect.stringContaining("maximum") }, }); expect(await agent.getAgentRoster()).toHaveLength(MAX_AGENTS_PER_DOC); // Revoking frees a slot. - await agent.revokeAgentToken("agent-0"); - expect(await agent.mintAgentToken({ name: "one-too-many" })).toHaveProperty("token"); + await agent.revokeAgentEntry("agent-0"); + expect(await agent.agentJoin(oneTooMany)).toEqual({ ok: true }); }); - it("returns doc_not_found when minting before the doc exists", async () => { - expect(await agent.mintAgentToken({ name: "scribe" })).toMatchObject({ + it("returns doc_not_found when enrolling before the doc exists", async () => { + expect(await agent.agentJoin(identity())).toMatchObject({ error: { code: "doc_not_found" }, }); }); - it("returns invalid_token for an unknown token", async () => { + it("returns invalid_token for a malformed identity", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - const v = await asVerifier(agent).verifyAgentToken("vpr_nonexistent"); + const v = await agent.agentJoin({} as never); expect(v).toMatchObject({ error: { code: "invalid_token" } }); }); it("verifies a granted capability and updates lastSeenAt", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - const minted = await agent.mintAgentToken({ name: "scribe" }); - const token = (minted as { token: string }).token; + const id = identity({ caps: ["suggest", "comment"] }); - const v = await asVerifier(agent).verifyAgentToken(token, "suggest"); - expect(v).toMatchObject({ entry: { name: "scribe" } }); + const read = await agent.agentRead(id); + expect("markdown" in read).toBe(true); const [entry] = await agent.getAgentRoster(); expect(entry.lastSeenAt).not.toBeNull(); @@ -869,12 +901,11 @@ describe("DocumentAgent", () => { it("updates lastSeenAt even when the capability check denies the call", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - const minted = await agent.mintAgentToken({ name: "scribe" }); // no write - const token = (minted as { token: string }).token; - expect((await agent.getAgentRoster())[0].lastSeenAt).toBeNull(); + const id = identity({ caps: ["suggest", "comment"] }); // no write + expect(await agent.getAgentRoster()).toHaveLength(0); - const v = await asVerifier(agent).verifyAgentToken(token, "write"); - expect(v).toMatchObject({ error: { code: "capability_denied" } }); + const denied = await agent.agentInsert(id, { where: "append", markdown: "x" }); + expect(denied).toMatchObject({ error: { code: "capability_denied" } }); // The agent was here — a denied call is still a sighting, and presence // is derived from lastSeenAt. @@ -883,73 +914,48 @@ describe("DocumentAgent", () => { it("assigns roster colors round-robin by roster size", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - await agent.mintAgentToken({ name: "first" }); - await agent.mintAgentToken({ name: "second" }); + await agent.agentJoin(identity({ id: "email:first@x.com", name: "first" })); + await agent.agentJoin(identity({ id: "email:second@x.com", name: "second" })); const roster = await agent.getAgentRoster(); expect(roster[0].color).not.toBe(roster[1].color); }); - it("clears the roster and invalidates tokens on doc expiry (alarm)", async () => { - await agent.onRequest(new Request("https://do/", { method: "POST" })); - const minted = await agent.mintAgentToken({ name: "scribe" }); - const token = (minted as { token: string }).token; - - await agent.alarm(); - - expect(await agent.getAgentRoster()).toEqual([]); - const v = await asVerifier(agent).verifyAgentToken(token); - expect(v).toMatchObject({ error: { code: "invalid_token" } }); - }); - }); - - describe("enrollAnonymousAgent", () => { - it("mints suggest+comment with owner null, using the given base name", async () => { + it("enrolls an anonymous identity with owner null and its given capabilities", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); - const enrolled = await agent.enrollAnonymousAgent("claude-code"); - expect("token" in enrolled && enrolled.token).toMatch(/^vpr_/); - expect(enrolled).toMatchObject({ - entry: { - name: "claude-code", - owner: null, - capabilities: ["suggest", "comment"], - }, + const anon = identity({ + kind: "anonymous", + id: "anon:session-1", + name: "claude-code", + owner: null, + caps: ["suggest", "comment"], }); + expect(await agent.agentJoin(anon)).toEqual({ ok: true }); const roster = await agent.getAgentRoster(); expect(roster).toHaveLength(1); - expect(roster[0]).toMatchObject({ name: "claude-code", owner: null }); + expect(roster[0]).toMatchObject({ + name: "claude-code", + owner: null, + capabilities: ["suggest", "comment"], + }); }); - it("retries with -2, -3, … on a name collision", async () => { + it("clears the roster on doc expiry (alarm), and a subsequent RPC re-enrolls fresh", async () => { await agent.onRequest(new Request("https://do/", { method: "POST" })); + const id = identity(); + await agent.agentJoin(id); - await agent.mintAgentToken({ name: "claude-code" }); - const second = await agent.enrollAnonymousAgent("claude-code"); - expect(second).toMatchObject({ entry: { name: "claude-code-2" } }); + await agent.alarm(); - const third = await agent.enrollAnonymousAgent("claude-code"); - expect(third).toMatchObject({ entry: { name: "claude-code-3" } }); - }); + expect(await agent.getAgentRoster()).toEqual([]); - it("returns rate_limited once the roster is at MAX_AGENTS_PER_DOC, without renaming forever", async () => { + // The document itself is gone too, so a bare re-enrollment attempt + // still needs a fresh doc before it can succeed. await agent.onRequest(new Request("https://do/", { method: "POST" })); - - for (let i = 0; i < MAX_AGENTS_PER_DOC; i++) { - expect(await agent.mintAgentToken({ name: `agent-${i}` })).toHaveProperty("token"); - } - - const enrolled = await agent.enrollAnonymousAgent("agent"); - expect(enrolled).toMatchObject({ - error: { code: "rate_limited", message: expect.stringContaining("maximum") }, - }); - expect(await agent.getAgentRoster()).toHaveLength(MAX_AGENTS_PER_DOC); - }); - - it("falls back to the doc_not_found error when the doc doesn't exist yet", async () => { - const enrolled = await agent.enrollAnonymousAgent("claude-code"); - expect(enrolled).toMatchObject({ error: { code: "doc_not_found" } }); + expect(await agent.agentJoin(id)).toEqual({ ok: true }); + expect(await agent.getAgentRoster()).toHaveLength(1); }); }); @@ -958,24 +964,24 @@ describe("DocumentAgent", () => { /* ================================================================ */ describe("agent mutations", () => { - async function setup(caps?: AgentCapability[]) { + async function setup(caps: AgentCapability[] = ["suggest", "comment"]) { const agent = makeAgent(); await agent.onRequest(new Request("https://do/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "# Title\n\nBody." }), })); - const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); - return { agent, token: (m as { token: string }).token }; + const id = identity({ caps }); + return { agent, id }; } it("reads markdown with anchors", async () => { - const { agent, token } = await setup(); - const r = await agent.agentRead(token); + const { agent, id } = await setup(); + const r = await agent.agentRead(id); expect("markdown" in r && r.markdown).toBe("# Title\n\nBody."); expect("blocks" in r && r.blocks[0].anchor).toMatch(/^b0-[0-9a-f]{8}$/); }); - it("exportMarkdown returns the document's markdown with no token", async () => { + it("exportMarkdown returns the document's markdown with no identity", async () => { const { agent } = await setup(); const r = await agent.exportMarkdown(); expect(r).toEqual({ markdown: "# Title\n\nBody." }); @@ -988,52 +994,52 @@ describe("DocumentAgent", () => { }); it("denies write without capability, allows with it", async () => { - const { agent, token } = await setup(); // default: no write - const denied = await agent.agentInsert(token, { where: "append", markdown: "More." }); + const { agent, id } = await setup(); // default: no write + const denied = await agent.agentInsert(id, { where: "append", markdown: "More." }); expect(denied).toMatchObject({ error: { code: "capability_denied" } }); - const { agent: a2, token: t2 } = await setup(["write"]); - await a2.agentInsert(t2, { where: "append", markdown: "More." }); - const r = await a2.agentRead(t2); + const { agent: a2, id: id2 } = await setup(["write"]); + await a2.agentInsert(id2, { where: "append", markdown: "More." }); + const r = await a2.agentRead(id2); expect("markdown" in r && r.markdown).toContain("More."); }); it("suggest lays critic marks", async () => { - const { agent, token } = await setup(); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["suggest"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[2].anchor; // "Body." - await agent.agentSuggest(token, { anchor, find: "Body.", replacement: "Better body." }); - const after = await agent.agentRead(token); + await agent.agentSuggest(id, { anchor, find: "Body.", replacement: "Better body." }); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toContain("{--Body.--}{++Better body.++}"); }); it("rejects a missing anchor without spending the rate-limit budget", async () => { - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); - const result = await agent.agentInsert(token, { where: "after", markdown: "Orphan." }); + const result = await agent.agentInsert(id, { where: "after", markdown: "Orphan." }); expect(result).toMatchObject({ error: { code: "stale_anchor" } }); - const row = (mockTables.get("agent_tokens") ?? []).find((r) => r.name === "scribe")!; + const row = (mockTables.get("roster") ?? []).find((r) => r.name === "scribe")!; expect(row.recent_mutations ?? null).toBeNull(); }); it("stale anchor errors after concurrent edit", async () => { - const { agent, token } = await setup(["write"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["write"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - await agent.agentReplace(token, { from: anchor, markdown: "# New title" }); - const stale = await agent.agentReplace(token, { from: anchor, markdown: "# Again" }); + await agent.agentReplace(id, { from: anchor, markdown: "# New title" }); + const stale = await agent.agentReplace(id, { from: anchor, markdown: "# Again" }); expect(stale).toMatchObject({ error: { code: "stale_anchor" } }); }); it("rejects an inverted range when to resolves before from", async () => { - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); // Append two blocks with identical text ("Same") so they share a // content hash. resolveAnchor's nearest-index heuristic then lets us // pick out either occurrence by fabricating an anchor whose *stated* // index is far from one occurrence and close to the other. - await agent.agentInsert(token, { where: "append", markdown: "Same\nOther\nSame\nEnd" }); + await agent.agentInsert(id, { where: "append", markdown: "Same\nOther\nSame\nEnd" }); - const before = await agent.agentRead(token); + const before = await agent.agentRead(id); const beforeMarkdown = "markdown" in before ? before.markdown : ""; const blocks = "blocks" in before ? before.blocks : []; const sameBlocks = blocks.filter((b) => b.text === "Same"); @@ -1045,10 +1051,10 @@ describe("DocumentAgent", () => { // "to" resolves to the earlier occurrence (nearest to stated index 0). const toAnchor = `b0-${hash}`; - const result = await agent.agentReplace(token, { from: fromAnchor, to: toAnchor, markdown: "Nope" }); + const result = await agent.agentReplace(id, { from: fromAnchor, to: toAnchor, markdown: "Nope" }); expect(result).toMatchObject({ error: { code: "stale_anchor" } }); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toBe(beforeMarkdown); }); @@ -1062,41 +1068,41 @@ describe("DocumentAgent", () => { }); it("applies instantly when there are no human connections, even at natural pace", async () => { - const { agent, token } = await setup(["write"]); - const result = await agent.agentInsert(token, { + const { agent, id } = await setup(["write"]); + const result = await agent.agentInsert(id, { where: "append", markdown: "Typed live.", pace: "natural", }); expect(result).toEqual({ ok: true }); - const read = await agent.agentRead(token); + const read = await agent.agentRead(id); expect("markdown" in read && read.markdown).toContain("Typed live."); }); it("applies instantly regardless of pace when pace is 'instant'", async () => { - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: "Pasted in.", pace: "instant", }); expect(result).toEqual({ ok: true }); - const read = await agent.agentRead(token); + const read = await agent.agentRead(id); expect("markdown" in read && read.markdown).toContain("Pasted in."); }); it("enqueues and types out a natural-pace insert while a human is connected", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); // No punctuation, so no sentence pauses — keeps the timing math // below simple. 78 chars. const fullText = "abcdefghijklmnopqrstuvwxyz".repeat(3); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: fullText, pace: "natural", @@ -1107,7 +1113,7 @@ describe("DocumentAgent", () => { // before agentInsert resolves, but nothing has been typed into it // yet — the first character requires the first tick's delay to // elapse. - const beforeAnyTick = await agent.agentRead(token); + const beforeAnyTick = await agent.agentRead(id); const blocksBefore = "blocks" in beforeAnyTick ? beforeAnyTick.blocks : []; expect(blocksBefore[3]?.text ?? "").toBe(""); @@ -1117,7 +1123,7 @@ describe("DocumentAgent", () => { // 390ms) — so this is genuinely partial, not a fluke of timing. await vi.advanceTimersByTimeAsync(100); - const afterFirstTick = await agent.agentRead(token); + const afterFirstTick = await agent.agentRead(id); const partialBlock = ("blocks" in afterFirstTick ? afterFirstTick.blocks : [])[3]; const partialLength = partialBlock?.text.length ?? 0; expect(partialLength).toBeGreaterThan(0); @@ -1125,20 +1131,20 @@ describe("DocumentAgent", () => { await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toContain(fullText); }); it("applies a leftover queued mutation instantly on restart (eviction recovery)", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); // Busy the runner with a slow first mutation so the second one's // turn never comes — its row is claimed (and deleted) the instant // its own typing starts, which happens synchronously as part of // *this* call. - await agent.agentInsert(token, { + await agent.agentInsert(id, { where: "append", markdown: "abcdefghijklmnopqrstuvwxyz".repeat(3), pace: "natural", @@ -1147,7 +1153,7 @@ describe("DocumentAgent", () => { // This second mutation is still sitting behind the first in the // queue, completely untouched — pre-first-write, so its row is // still fully intact in `performances`. - await agent.agentInsert(token, { + await agent.agentInsert(id, { where: "append", markdown: "Recovered text.", pace: "natural", @@ -1158,21 +1164,20 @@ describe("DocumentAgent", () => { // advancing time (so the busy first mutation never finishes, and // the second mutation's row is never touched by the runner). const agent2 = new DocumentAgent({} as never, {} as never); - const read = await agent2.agentRead(token); + const read = await agent2.agentRead(id); expect("markdown" in read && read.markdown).toContain("Recovered text."); }); it("keeps both texts present exactly once, in sane positions, despite a concurrent instant append", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); - const minted2 = await agent.mintAgentToken({ name: "bot2", capabilities: ["write"] }); - const token2 = (minted2 as { token: string }).token; + const id2 = identity({ id: "email:bot2@x.com", name: "bot2", caps: ["write"] }); // Starts typing "Slow typed line" at natural pace — claims its // block slot synchronously, before any ticks fire. - const pacedResult = await agent.agentInsert(token, { + const pacedResult = await agent.agentInsert(id, { where: "append", markdown: "Slow typed line", pace: "natural", @@ -1181,7 +1186,7 @@ describe("DocumentAgent", () => { // A second agent's instant append lands while the first is still // mid-typing. - const instantResult = await agent.agentInsert(token2, { + const instantResult = await agent.agentInsert(id2, { where: "append", markdown: "Instant line", pace: "instant", @@ -1190,7 +1195,7 @@ describe("DocumentAgent", () => { await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); const markdown = "markdown" in after ? after.markdown : ""; const blocks = "blocks" in after ? after.blocks : []; const texts = blocks.map((b) => b.text); @@ -1204,20 +1209,19 @@ describe("DocumentAgent", () => { it("keeps an anchored typed insert on the correct side of its anchor despite a concurrent instant insert before it", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); - const minted2 = await agent.mintAgentToken({ name: "bot2", capabilities: ["write"] }); - const token2 = (minted2 as { token: string }).token; + const id2 = identity({ id: "email:bot2@x.com", name: "bot2", caps: ["write"] }); - const before = await agent.agentRead(token); + const before = await agent.agentRead(id); const anchor0 = ("blocks" in before ? before.blocks : [])[0].anchor; // "# Title" // Starts typing "Slow typed line" right after the title, at // natural pace. This claims its slot (right after block 0) // synchronously, before any ticks fire — the block index it // resolved to is only valid up to that point. - const pacedResult = await agent.agentInsert(token, { + const pacedResult = await agent.agentInsert(id, { where: "after", anchor: anchor0, markdown: "Slow typed line", @@ -1230,7 +1234,7 @@ describe("DocumentAgent", () => { // its originally-resolved raw index instead of tracking the // paragraph itself, this would land the typed text *before* the // title it was supposed to follow. - const instantResult = await agent.agentInsert(token2, { + const instantResult = await agent.agentInsert(id2, { where: "before", anchor: anchor0, markdown: "Preamble", @@ -1240,7 +1244,7 @@ describe("DocumentAgent", () => { await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); const markdown = "markdown" in after ? after.markdown : ""; const blocks = "blocks" in after ? after.blocks : []; const texts = blocks.map((b) => b.text); @@ -1259,21 +1263,21 @@ describe("DocumentAgent", () => { it("drops a queued mutation whose anchor goes stale before its turn", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); - const before = await agent.agentRead(token); + const before = await agent.agentRead(id); const anchor0 = ("blocks" in before ? before.blocks : [])[0].anchor; // "# Title" // Busy the runner with a slow natural-pace insert. - await agent.agentInsert(token, { + await agent.agentInsert(id, { where: "append", markdown: "Long enough text to take a few typing ticks.", pace: "natural", }); // Queue a replace behind it, targeting the still-fresh anchor0. - const queuedReplace = agent.agentReplace(token, { + const queuedReplace = agent.agentReplace(id, { from: anchor0, markdown: "Replaced!", pace: "natural", @@ -1282,7 +1286,7 @@ describe("DocumentAgent", () => { // An instant edit invalidates anchor0 before the queued replace // gets its turn. - const instantEdit = await agent.agentReplace(token, { + const instantEdit = await agent.agentReplace(id, { from: anchor0, markdown: "Changed first!", pace: "instant", @@ -1291,7 +1295,7 @@ describe("DocumentAgent", () => { await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); const markdown = "markdown" in after ? after.markdown : ""; expect(markdown).toContain("Changed first!"); expect(markdown).not.toContain("Replaced!"); @@ -1323,10 +1327,10 @@ describe("DocumentAgent", () => { }); it("agentInsert returns unsupported_markup and leaves the document untouched", async () => { - const { agent, token } = await setup(["write"]); - const before = await agent.agentRead(token); + const { agent, id } = await setup(["write"]); + const before = await agent.agentRead(id); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: SUBSTITUTION, pace: "instant", @@ -1335,19 +1339,19 @@ describe("DocumentAgent", () => { expect(result).toMatchObject({ error: { code: "unsupported_markup", message: expect.stringContaining("substitution") }, }); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toBe( "markdown" in before ? before.markdown : "", ); }); it("agentReplace returns unsupported_markup without deleting the blocks it would replace", async () => { - const { agent, token } = await setup(["write"]); - const before = await agent.agentRead(token); + const { agent, id } = await setup(["write"]); + const before = await agent.agentRead(id); const beforeMarkdown = "markdown" in before ? before.markdown : ""; const anchor = ("blocks" in before ? before.blocks : [])[0].anchor; - const result = await agent.agentReplace(token, { + const result = await agent.agentReplace(id, { from: anchor, markdown: SUBSTITUTION, pace: "instant", @@ -1357,15 +1361,15 @@ describe("DocumentAgent", () => { // The data-loss case: deleteBlocks and insertMarkdownBlocks used to // share one transaction, and Yjs cannot roll a transaction back, so // a parse failure between them committed the delete. - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toBe(beforeMarkdown); }); it("rejects unsupported markup at any pace, without queueing it", async () => { - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: SUBSTITUTION, pace: "natural", @@ -1377,7 +1381,7 @@ describe("DocumentAgent", () => { it("drains the queue past a queued mutation with unsupported markup", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); asQueue(agent).enqueuePerformance("scribe", "fast", { @@ -1385,7 +1389,7 @@ describe("DocumentAgent", () => { where: "append", markdown: SUBSTITUTION, }); - await agent.agentInsert(token, { + await agent.agentInsert(id, { where: "append", markdown: "Good text.", pace: "fast", @@ -1393,7 +1397,7 @@ describe("DocumentAgent", () => { await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); const markdown = "markdown" in after ? after.markdown : ""; expect(markdown).toContain("Good text."); expect(markdown).not.toContain("~>"); @@ -1403,7 +1407,7 @@ describe("DocumentAgent", () => { it("does not wedge the queue when a queued mutation throws", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); createConnection(); // A payload no code path can apply: `markdown` isn't a string, so @@ -1420,19 +1424,19 @@ describe("DocumentAgent", () => { expect(asQueue(agent).isPerforming).toBe(false); expect(mockTables.get("performances") ?? []).toEqual([]); - await agent.agentInsert(token, { + await agent.agentInsert(id, { where: "append", markdown: "Still working.", pace: "fast", }); await vi.runAllTimersAsync(); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("markdown" in after && after.markdown).toContain("Still working."); }); it("recovers from a poisoned leftover performance row on restart", async () => { - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); // An eviction mid-queue leaves rows behind. This one can't be // applied at all — a throw here used to abort ensureInitialised with @@ -1444,7 +1448,7 @@ describe("DocumentAgent", () => { ]); const agent2 = new DocumentAgent({} as never, {} as never); - const read = await agent2.agentRead(token); + const read = await agent2.agentRead(id); expect("markdown" in read).toBe(true); expect(mockTables.get("performances") ?? []).toEqual([]); @@ -1453,11 +1457,11 @@ describe("DocumentAgent", () => { const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; const ytext = para.get(0) as Y.XmlText; ytext.insert(ytext.length, " ping @scribe"); - const events = await agent2.agentAwaitEvents(token, {}); + const events = await agent2.agentAwaitEvents(id, {}); expect(("events" in events ? events.events : []).some((e) => e.type === "mention")).toBe(true); // And the reused performance id no longer collides with a survivor. - const queued = await agent2.agentInsert(token, { + const queued = await agent2.agentInsert(id, { where: "append", markdown: "After recovery.", pace: "natural", @@ -1474,36 +1478,37 @@ describe("DocumentAgent", () => { describe("corrupt stored state", () => { it("agentRead skips an unparseable thread rather than throwing", async () => { - const { agent, token } = await setup(["comment"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["comment"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - await agent.agentComment(token, { anchor, text: "fine" }); + await agent.agentComment(id, { anchor, text: "fine" }); const client = connectYjsClient(agent); client.doc.getMap("threads").set("broken", "{ not json"); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); expect("threads" in after && after.threads).toHaveLength(1); expect("threads" in after && after.threads[0].commentText).toBe("fine"); cleanup(client); }); it("agentReply returns thread_not_found for an unparseable thread", async () => { - const { agent, token } = await setup(["comment"]); + const { agent, id } = await setup(["comment"]); const client = connectYjsClient(agent); client.doc.getMap("threads").set("broken", "{ not json"); - const result = await agent.agentReply(token, { threadId: "broken", text: "hi" }); + const result = await agent.agentReply(id, { threadId: "broken", text: "hi" }); expect(result).toMatchObject({ error: { code: "thread_not_found" } }); cleanup(client); }); it("treats an unparseable rate-limit log as empty and rewrites it", async () => { - const { agent, token } = await setup(["write"]); - const row = (mockTables.get("agent_tokens") ?? []).find((r) => r.name === "scribe")!; + const { agent, id } = await setup(["write"]); + await agent.agentJoin(id); // enroll first — the roster row doesn't exist until an RPC lands + const row = (mockTables.get("roster") ?? []).find((r) => r.name === "scribe")!; row.recent_mutations = "{ not json"; - const result = await agent.agentInsert(token, { + const result = await agent.agentInsert(id, { where: "append", markdown: "Fine.", pace: "instant", @@ -1524,14 +1529,14 @@ describe("DocumentAgent", () => { vi.useRealTimers(); }); - async function setup(caps?: AgentCapability[]) { + async function setup(caps: AgentCapability[] = ["suggest", "comment"]) { const agent = makeAgent(); await agent.onRequest(new Request("https://do/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "# Title\n\nBody." }), })); - const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); - return { agent, token: (m as { token: string }).token }; + const id = identity({ caps }); + return { agent, id }; } /** Finds the (at most one) agent presence state among a client's awareness states. */ @@ -1542,11 +1547,11 @@ describe("DocumentAgent", () => { } it("broadcasts a presence state every connected client can decode", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const a = connectYjsClient(agent); const b = connectYjsClient(agent); - const result = await agent.agentJoin(token, "typing"); + const result = await agent.agentJoin(id, "typing"); expect(result).toEqual({ ok: true }); for (const client of [a, b]) { @@ -1559,8 +1564,8 @@ describe("DocumentAgent", () => { }); it("replays current agent presence to a client that connects after join", async () => { - const { agent, token } = await setup(); - await agent.agentJoin(token); + const { agent, id } = await setup(); + await agent.agentJoin(id); const late = connectYjsClient(agent); expect(findAgentState(late.awareness)).toMatchObject({ @@ -1577,32 +1582,32 @@ describe("DocumentAgent", () => { }); it("removes presence for all connections immediately on leave", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const a = connectYjsClient(agent); - await agent.agentJoin(token); + await agent.agentJoin(id); expect(findAgentState(a.awareness)).toBeDefined(); - const result = await agent.agentLeave(token); + const result = await agent.agentLeave(id); expect(result).toEqual({ ok: true }); expect(findAgentState(a.awareness)).toBeUndefined(); cleanup(a); }); - it("rejects join/leave for an invalid token", async () => { + it("rejects join/leave for a malformed identity", async () => { const { agent } = await setup(); - expect(await agent.agentJoin("vpr_nonexistent")).toMatchObject({ + expect(await agent.agentJoin({} as never)).toMatchObject({ error: { code: "invalid_token" }, }); - expect(await agent.agentLeave("vpr_nonexistent")).toMatchObject({ + expect(await agent.agentLeave({} as never)).toMatchObject({ error: { code: "invalid_token" }, }); }); it("removes presence automatically after 5 minutes of inactivity", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(); + const { agent, id } = await setup(); const a = connectYjsClient(agent); - await agent.agentJoin(token); + await agent.agentJoin(id); await vi.advanceTimersByTimeAsync(5 * 60 * 1000 - 1); expect(findAgentState(a.awareness)).toBeDefined(); @@ -1614,9 +1619,9 @@ describe("DocumentAgent", () => { it("resets the idle timer on every performance, keeping a busy agent present", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); const a = connectYjsClient(agent); - await agent.agentJoin(token); + await agent.agentJoin(id); // Just under the idle window, perform a (quick) mutation — its // typing ticks call onPerformanceCursor, which resets the timer. Only @@ -1624,7 +1629,7 @@ describe("DocumentAgent", () => { // vi.runAllTimersAsync() would also drain the *freshly reset* 5-minute // idle timeout in the same call, defeating the point of the test. await vi.advanceTimersByTimeAsync(4 * 60 * 1000); - await agent.agentInsert(token, { where: "append", markdown: "hi", pace: "natural" }); + await agent.agentInsert(id, { where: "append", markdown: "hi", pace: "natural" }); await vi.advanceTimersByTimeAsync(200); // Another 4 minutes — past the original 5-minute mark from join, but @@ -1636,14 +1641,14 @@ describe("DocumentAgent", () => { it("populates a y-tiptap-shaped cursor field during a performance, even for an agent that never joined", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const { agent, token } = await setup(["write"]); + const { agent, id } = await setup(["write"]); const a = connectYjsClient(agent); // Bounded advance, not vi.runAllTimersAsync(): each tick's // onPerformanceCursor resets a fresh 5-minute idle timeout, which // runAllTimersAsync() would drain too, removing presence again before // this assertion runs. - await agent.agentInsert(token, { where: "append", markdown: "abcdefghij", pace: "natural" }); + await agent.agentInsert(id, { where: "append", markdown: "abcdefghij", pace: "natural" }); await vi.advanceTimersByTimeAsync(800); const state = findAgentState(a.awareness); @@ -1657,9 +1662,9 @@ describe("DocumentAgent", () => { }); it("clears agent presence and idle timers on alarm", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const a = connectYjsClient(agent); - await agent.agentJoin(token); + await agent.agentJoin(id); expect(findAgentState(a.awareness)).toBeDefined(); await agent.alarm(); @@ -1680,18 +1685,23 @@ describe("DocumentAgent", () => { vi.useRealTimers(); }); - async function setup(caps?: AgentCapability[]) { + async function setup(caps: AgentCapability[] = ["suggest", "comment"]) { const agent = makeAgent(); await agent.onRequest(new Request("https://do/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "Hello there." }), })); - const m = await agent.mintAgentToken({ name: "scribe", capabilities: caps }); - return { agent, token: (m as { token: string }).token }; + const id = identity({ caps }); + // Enrollment is implicit on an agent's first RPC call — the mention/ + // thread_reply/doc_changed observers only fire once the roster is + // non-empty, so this agent must be on it *before* any human edit the + // test makes, exactly as an explicit mint used to guarantee. + await agent.agentJoin(id); + return { agent, id }; } it("records a mention through the real Yjs sync path when a human edits an existing block", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const client = connectYjsClient(agent); // A human types more text into the already-synced first paragraph — @@ -1701,7 +1711,7 @@ describe("DocumentAgent", () => { const ytext = para.get(0) as Y.XmlText; ytext.insert(ytext.length, " ping @scribe please"); - const result = await agent.agentAwaitEvents(token, {}); + const result = await agent.agentAwaitEvents(id, {}); expect("events" in result).toBe(true); const events = "events" in result ? result.events : []; const mention = events.find((e) => e.type === "mention"); @@ -1716,7 +1726,7 @@ describe("DocumentAgent", () => { }); it("records exactly one mention when a human types @scribe one character at a time", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const client = connectYjsClient(agent); const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; @@ -1727,7 +1737,7 @@ describe("DocumentAgent", () => { ytext.insert(ytext.length, ch); } - const result = await agent.agentAwaitEvents(token, {}); + const result = await agent.agentAwaitEvents(id, {}); const events = "events" in result ? result.events : []; const mentions = events.filter((e) => e.type === "mention"); expect(mentions).toHaveLength(1); @@ -1741,7 +1751,7 @@ describe("DocumentAgent", () => { for (const ch of ", please") { ytext.insert(ytext.length, ch); } - const second = await agent.agentAwaitEvents(token, { cursor, timeoutMs: 20 }); + const second = await agent.agentAwaitEvents(id, { cursor, timeoutMs: 20 }); const secondEvents = "events" in second ? second.events : []; expect(secondEvents.filter((e) => e.type === "mention")).toHaveLength(0); @@ -1749,7 +1759,7 @@ describe("DocumentAgent", () => { }); it("re-fires a mention after it is deleted and retyped", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const client = connectYjsClient(agent); const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; @@ -1758,7 +1768,7 @@ describe("DocumentAgent", () => { for (const ch of " @scribe") { ytext.insert(ytext.length, ch); } - const first = await agent.agentAwaitEvents(token, {}); + const first = await agent.agentAwaitEvents(id, {}); const cursor = "cursor" in first ? first.cursor : 0; ytext.delete(base, ytext.length - base); @@ -1766,7 +1776,7 @@ describe("DocumentAgent", () => { ytext.insert(ytext.length, ch); } - const second = await agent.agentAwaitEvents(token, { cursor }); + const second = await agent.agentAwaitEvents(id, { cursor }); const mentions = ("events" in second ? second.events : []).filter( (e) => e.type === "mention", ); @@ -1791,7 +1801,7 @@ describe("DocumentAgent", () => { expect(mockTables.get("events") ?? []).toEqual([]); // With an agent on the roster, the digest is recorded as before. - await agent.mintAgentToken({ name: "scribe" }); + await agent.agentJoin(identity()); ytext.insert(ytext.length, " and another"); expect((mockTables.get("events") ?? []).map((r) => r.type)).toContain("doc_changed"); @@ -1799,9 +1809,8 @@ describe("DocumentAgent", () => { }); it("delivers a mention only to the agent it names", async () => { - const { agent, token } = await setup(); - const minted = await agent.mintAgentToken({ name: "muse" }); - const museToken = (minted as { token: string }).token; + const { agent, id } = await setup(); + const museId = identity({ id: "email:muse@x.com", name: "muse" }); const client = connectYjsClient(agent); const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; @@ -1810,11 +1819,11 @@ describe("DocumentAgent", () => { ytext.insert(ytext.length, ch); } - const forScribe = await agent.agentAwaitEvents(token, {}); + const forScribe = await agent.agentAwaitEvents(id, {}); const scribeEvents = "events" in forScribe ? forScribe.events : []; expect(scribeEvents.filter((e) => e.type === "mention")).toHaveLength(1); - const forMuse = await agent.agentAwaitEvents(museToken, { timeoutMs: 20 }); + const forMuse = await agent.agentAwaitEvents(museId, { timeoutMs: 20 }); const museEvents = "events" in forMuse ? forMuse.events : []; expect(museEvents.some((e) => e.type === "mention")).toBe(false); // The broadcast digest still reaches everyone. @@ -1828,13 +1837,12 @@ describe("DocumentAgent", () => { }); it("delivers a thread_reply only to the agent that authored the thread", async () => { - const { agent, token } = await setup(["comment"]); - const minted = await agent.mintAgentToken({ name: "muse", capabilities: ["comment"] }); - const museToken = (minted as { token: string }).token; + const { agent, id } = await setup(["comment"]); + const museId = identity({ id: "email:muse@x.com", name: "muse", caps: ["comment"] }); - const read = await agent.agentRead(token); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - const created = await agent.agentComment(token, { anchor, text: "needs work" }); + const created = await agent.agentComment(id, { anchor, text: "needs work" }); const threadId = "threadId" in created ? created.threadId : ""; const client = connectYjsClient(agent); @@ -1848,11 +1856,11 @@ describe("DocumentAgent", () => { }); threadsMap.set(threadId, JSON.stringify(thread)); - const forScribe = await agent.agentAwaitEvents(token, {}); + const forScribe = await agent.agentAwaitEvents(id, {}); expect(("events" in forScribe ? forScribe.events : []).some((e) => e.type === "thread_reply")) .toBe(true); - const forMuse = await agent.agentAwaitEvents(museToken, { timeoutMs: 20 }); + const forMuse = await agent.agentAwaitEvents(museId, { timeoutMs: 20 }); expect(("events" in forMuse ? forMuse.events : []).some((e) => e.type === "thread_reply")) .toBe(false); @@ -1860,10 +1868,10 @@ describe("DocumentAgent", () => { }); it("resolves empty after the timeout when no events occur (fake timers)", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const promise = agent.agentAwaitEvents(token, { timeoutMs: 50 }); + const promise = agent.agentAwaitEvents(id, { timeoutMs: 50 }); await waitForTimerRegistered(); await vi.advanceTimersByTimeAsync(50); const result = await promise; @@ -1872,19 +1880,19 @@ describe("DocumentAgent", () => { }); it("excludes already-seen events once the cursor advances past them", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const client = connectYjsClient(agent); const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; const ytext = para.get(0) as Y.XmlText; ytext.insert(ytext.length, " ping @scribe please"); - const first = await agent.agentAwaitEvents(token, {}); + const first = await agent.agentAwaitEvents(id, {}); const cursor = "cursor" in first ? first.cursor : -1; expect(cursor).toBeGreaterThan(0); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const secondPromise = agent.agentAwaitEvents(token, { cursor, timeoutMs: 50 }); + const secondPromise = agent.agentAwaitEvents(id, { cursor, timeoutMs: 50 }); await waitForTimerRegistered(); await vi.advanceTimersByTimeAsync(50); const second = await secondPromise; @@ -1894,15 +1902,15 @@ describe("DocumentAgent", () => { }); it("round-trips a comment and reply, and records a thread_reply event for a human reply", async () => { - const { agent, token } = await setup(["comment"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["comment"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - const created = await agent.agentComment(token, { anchor, quote: "Hello", text: "needs work" }); + const created = await agent.agentComment(id, { anchor, quote: "Hello", text: "needs work" }); expect("threadId" in created).toBe(true); const threadId = "threadId" in created ? created.threadId : ""; - const afterCreate = await agent.agentRead(token); + const afterCreate = await agent.agentRead(id); const threads = "threads" in afterCreate ? afterCreate.threads : []; expect(threads).toHaveLength(1); expect(threads[0]).toMatchObject({ @@ -1929,7 +1937,7 @@ describe("DocumentAgent", () => { }); threadsMap.set(threadId, JSON.stringify(thread)); - const result = await agent.agentAwaitEvents(token, {}); + const result = await agent.agentAwaitEvents(id, {}); const events = "events" in result ? result.events : []; const threadReply = events.find((e) => e.type === "thread_reply"); expect(threadReply).toMatchObject({ @@ -1941,40 +1949,40 @@ describe("DocumentAgent", () => { }); it("agentComment requires the comment capability", async () => { - const { agent, token } = await setup([]); - const read = await agent.agentRead(token); + const { agent, id } = await setup([]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - const result = await agent.agentComment(token, { anchor, text: "hi" }); + const result = await agent.agentComment(id, { anchor, text: "hi" }); expect(result).toMatchObject({ error: { code: "capability_denied" } }); }); it("agentReply appends a reply, attributed to the replying agent", async () => { - const { agent, token } = await setup(["comment"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["comment"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - const created = await agent.agentComment(token, { anchor, text: "hi" }); + const created = await agent.agentComment(id, { anchor, text: "hi" }); const threadId = "threadId" in created ? created.threadId : ""; - const result = await agent.agentReply(token, { threadId, text: "reply text" }); + const result = await agent.agentReply(id, { threadId, text: "reply text" }); expect(result).toEqual({ ok: true }); - const after = await agent.agentRead(token); + const after = await agent.agentRead(id); const threads = "threads" in after ? after.threads : []; expect(threads[0].replies).toHaveLength(1); expect(threads[0].replies[0]).toMatchObject({ text: "reply text", author: { name: "scribe" } }); }); it("agentReply returns thread_not_found for an unknown thread", async () => { - const { agent, token } = await setup(["comment"]); - const result = await agent.agentReply(token, { threadId: "nope", text: "x" }); + const { agent, id } = await setup(["comment"]); + const result = await agent.agentReply(id, { threadId: "nope", text: "x" }); expect(result).toMatchObject({ error: { code: "thread_not_found" } }); }); it("records a thread_reply event only when a reply is actually added, not on a resolve toggle", async () => { - const { agent, token } = await setup(["comment"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["comment"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; - const created = await agent.agentComment(token, { anchor, text: "needs work" }); + const created = await agent.agentComment(id, { anchor, text: "needs work" }); const threadId = "threadId" in created ? created.threadId : ""; const client = connectYjsClient(agent); @@ -1986,7 +1994,7 @@ describe("DocumentAgent", () => { const beforeResolve = JSON.parse(threadsMap.get(threadId)!); threadsMap.set(threadId, JSON.stringify({ ...beforeResolve, resolved: true })); - const afterResolve = await agent.agentAwaitEvents(token, { timeoutMs: 20 }); + const afterResolve = await agent.agentAwaitEvents(id, { timeoutMs: 20 }); const eventsAfterResolve = "events" in afterResolve ? afterResolve.events : []; expect(eventsAfterResolve.some((e) => e.type === "thread_reply")).toBe(false); const cursorAfterResolve = "cursor" in afterResolve ? afterResolve.cursor : 0; @@ -2001,7 +2009,7 @@ describe("DocumentAgent", () => { }); threadsMap.set(threadId, JSON.stringify(beforeReply)); - const afterReply = await agent.agentAwaitEvents(token, { cursor: cursorAfterResolve }); + const afterReply = await agent.agentAwaitEvents(id, { cursor: cursorAfterResolve }); const eventsAfterReply = "events" in afterReply ? afterReply.events : []; const threadReplyEvents = eventsAfterReply.filter((e) => e.type === "thread_reply"); expect(threadReplyEvents).toHaveLength(1); @@ -2011,34 +2019,34 @@ describe("DocumentAgent", () => { }); it("agentComment and agentReply are rate-limited like the other mutation RPCs", async () => { - const { agent, token } = await setup(["comment"]); - const read = await agent.agentRead(token); + const { agent, id } = await setup(["comment"]); + const read = await agent.agentRead(id); const anchor = ("blocks" in read ? read.blocks : [])[0].anchor; // Pre-fill this agent's rate-limit log at the per-minute mutation // cap, driving checkRateLimit's denial path directly rather than via // 10 real calls. - const tokenRows = mockTables.get("agent_tokens") ?? []; - const row = tokenRows.find((r) => r.name === "scribe")!; + const rosterRows = mockTables.get("roster") ?? []; + const row = rosterRows.find((r) => r.name === "scribe")!; const now = Date.now(); row.recent_mutations = JSON.stringify( Array.from({ length: 10 }, () => ({ at: now, chars: 1 })), ); - const commentResult = await agent.agentComment(token, { anchor, text: "hi" }); + const commentResult = await agent.agentComment(id, { anchor, text: "hi" }); expect(commentResult).toMatchObject({ error: { code: "rate_limited" } }); - const replyResult = await agent.agentReply(token, { threadId: "whatever", text: "hi" }); + const replyResult = await agent.agentReply(id, { threadId: "whatever", text: "hi" }); expect(replyResult).toMatchObject({ error: { code: "rate_limited" } }); }); it("prunes events on doc expiry (alarm)", async () => { - const { agent, token } = await setup(); + const { agent, id } = await setup(); const client = connectYjsClient(agent); const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; const ytext = para.get(0) as Y.XmlText; ytext.insert(ytext.length, " ping @scribe please"); - await agent.agentAwaitEvents(token, {}); + await agent.agentAwaitEvents(id, {}); cleanup(client); await agent.alarm(); diff --git a/tests/unit/agents/mcp-anonymous.test.ts b/tests/unit/agents/mcp-anonymous.test.ts deleted file mode 100644 index 142ec081..00000000 --- a/tests/unit/agents/mcp-anonymous.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { runAnonymousTool, type AnonymousAgentState } from "../../../agents/mcp-anonymous"; -import { TOOLS } from "../../../agents/mcp-tools"; - -const readTool = TOOLS.find((t) => t.name === "read_document")!; - -function makeStub(overrides: Record = {}) { - return { - agentRead: vi.fn(async () => ({ markdown: "# Hi", blocks: [], presence: [], threads: [] })), - enrollAnonymousAgent: vi.fn(async () => ({ - token: "vpr_anon1", - entry: { - name: "claude-code", - color: "coral", - owner: null, - capabilities: ["suggest", "comment"], - createdAt: 1, - lastSeenAt: null, - }, - })), - ...overrides, - }; -} - -describe("runAnonymousTool", () => { - it("enrolls once on the first call for a doc and persists the identity", async () => { - const stub = makeStub(); - let state: AnonymousAgentState = {}; - const setState = vi.fn((next: AnonymousAgentState) => { - state = next; - }); - - const out = await runAnonymousTool({ - tool: readTool, - args: { doc_id: "abcd1234" }, - getStub: async () => stub as never, - baseName: "claude-code", - state, - setState, - }); - - expect(stub.enrollAnonymousAgent).toHaveBeenCalledWith("claude-code"); - expect(stub.agentRead).toHaveBeenCalledWith("vpr_anon1"); - expect(setState).toHaveBeenCalledWith({ - abcd1234: { token: "vpr_anon1", name: "claude-code" }, - }); - expect(out).toMatchObject({ markdown: "# Hi" }); - }); - - it("reuses a held token on a second call instead of enrolling again", async () => { - const stub = makeStub(); - const state: AnonymousAgentState = { abcd1234: { token: "vpr_held", name: "claude-code" } }; - const setState = vi.fn(); - - const out = await runAnonymousTool({ - tool: readTool, - args: { doc_id: "abcd1234" }, - getStub: async () => stub as never, - baseName: "claude-code", - state, - setState, - }); - - expect(stub.enrollAnonymousAgent).not.toHaveBeenCalled(); - expect(stub.agentRead).toHaveBeenCalledWith("vpr_held"); - expect(setState).not.toHaveBeenCalled(); - expect(out).toMatchObject({ markdown: "# Hi" }); - }); - - it("keeps separate identities per doc_id in the same session", async () => { - const stub = makeStub(); - let state: AnonymousAgentState = { abcd1234: { token: "vpr_held", name: "claude-code" } }; - const setState = vi.fn((next: AnonymousAgentState) => { - state = next; - }); - - await runAnonymousTool({ - tool: readTool, - args: { doc_id: "wxyz5678" }, - getStub: async () => stub as never, - baseName: "claude-code", - state, - setState, - }); - - expect(state).toEqual({ - abcd1234: { token: "vpr_held", name: "claude-code" }, - wxyz5678: { token: "vpr_anon1", name: "claude-code" }, - }); - }); - - it("surfaces an enrollment failure as error content, without persisting state", async () => { - const stub = makeStub({ - enrollAnonymousAgent: vi.fn(async () => ({ - error: { code: "rate_limited", message: "This document already has the maximum of 16 agents." }, - })), - }); - const setState = vi.fn(); - - const out = await runAnonymousTool({ - tool: readTool, - args: { doc_id: "abcd1234" }, - getStub: async () => stub as never, - baseName: "claude-code", - state: {}, - setState, - }); - - expect(stub.agentRead).not.toHaveBeenCalled(); - expect(setState).not.toHaveBeenCalled(); - expect(out).toMatchObject({ error: { code: "rate_limited" } }); - }); - - it("skips enrollment and defers to the tool's own validation for a malformed doc_id", async () => { - const getStub = vi.fn(); - const setState = vi.fn(); - - const out = await runAnonymousTool({ - tool: readTool, - args: { doc_id: "NOT-AN-ID" }, - getStub: getStub as never, - baseName: "claude-code", - state: {}, - setState, - }); - - expect(getStub).not.toHaveBeenCalled(); - expect(setState).not.toHaveBeenCalled(); - expect(out).toMatchObject({ error: { code: "doc_not_found" } }); - }); -}); diff --git a/tests/unit/agents/mcp-tools.test.ts b/tests/unit/agents/mcp-tools.test.ts index 92ce5013..3547d804 100644 --- a/tests/unit/agents/mcp-tools.test.ts +++ b/tests/unit/agents/mcp-tools.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import { TOOLS, validateNewDocumentMarkdown, createDocumentAgentName } from "../../../agents/mcp-tools"; +import type { AgentIdentity } from "../../../app/shared/agent-protocol"; + +const ID: AgentIdentity = { + kind: "principal", + id: "email:a@x.com", + name: "scribe", + owner: "email:a@x.com", + caps: ["suggest", "comment", "write"], +}; const SPEC_TOOLS = [ "read_document", @@ -27,7 +36,7 @@ describe("mcp tool table", () => { } }); - it("routes read_document to the stub with the bearer token", async () => { + it("routes read_document to the stub with the verified identity", async () => { const stub = { agentRead: vi.fn(async () => ({ markdown: "# Hi", @@ -38,10 +47,10 @@ describe("mcp tool table", () => { }; const tool = TOOLS.find((t) => t.name === "read_document")!; const out = await tool.run( - { getStub: async () => stub as never, token: "vpr_t" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234" }, ); - expect(stub.agentRead).toHaveBeenCalledWith("vpr_t"); + expect(stub.agentRead).toHaveBeenCalledWith(ID); expect(out).toMatchObject({ markdown: "# Hi" }); }); @@ -49,7 +58,7 @@ describe("mcp tool table", () => { const getStub = vi.fn(); const tool = TOOLS.find((t) => t.name === "read_document")!; const out = await tool.run( - { getStub: getStub as never, token: "vpr_t" }, + { getStub: getStub as never, identity: ID }, { doc_id: "NOT-AN-ID" }, ); expect(getStub).not.toHaveBeenCalled(); @@ -60,10 +69,10 @@ describe("mcp tool table", () => { const stub = { agentInsert: vi.fn(async () => ({ ok: true })) }; const tool = TOOLS.find((t) => t.name === "insert")!; const out = await tool.run( - { getStub: async () => stub as never, token: "vpr_t" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234", anchor: "b1-aaaabbbb", where: "after", markdown: "hi", pace: "instant" }, ); - expect(stub.agentInsert).toHaveBeenCalledWith("vpr_t", { + expect(stub.agentInsert).toHaveBeenCalledWith(ID, { anchor: "b1-aaaabbbb", where: "after", markdown: "hi", @@ -76,10 +85,10 @@ describe("mcp tool table", () => { const stub = { agentReplace: vi.fn(async () => ({ ok: true })) }; const tool = TOOLS.find((t) => t.name === "replace")!; await tool.run( - { getStub: async () => stub as never, token: "vpr_t" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234", from_anchor: "b1-aaaabbbb", to_anchor: "b2-ccccdddd", markdown: "x" }, ); - expect(stub.agentReplace).toHaveBeenCalledWith("vpr_t", { + expect(stub.agentReplace).toHaveBeenCalledWith(ID, { from: "b1-aaaabbbb", to: "b2-ccccdddd", markdown: "x", @@ -91,10 +100,10 @@ describe("mcp tool table", () => { const stub = { agentSuggest: vi.fn(async () => ({ ok: true })) }; const tool = TOOLS.find((t) => t.name === "suggest")!; await tool.run( - { getStub: async () => stub as never, token: "vpr_t" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234", anchor: "b1-aaaabbbb", find: "old", replacement: "new" }, ); - expect(stub.agentSuggest).toHaveBeenCalledWith("vpr_t", { + expect(stub.agentSuggest).toHaveBeenCalledWith(ID, { anchor: "b1-aaaabbbb", find: "old", replacement: "new", @@ -107,7 +116,7 @@ describe("mcp tool table", () => { agentComment: vi.fn(async () => ({ threadId: "t1" })), agentReply: vi.fn(async () => ({ ok: true })), }; - const deps = { getStub: async () => stub as never, token: "vpr_t" }; + const deps = { getStub: async () => stub as never, identity: ID }; const comment = await TOOLS.find((t) => t.name === "comment")!.run(deps, { doc_id: "abcd1234", @@ -115,7 +124,7 @@ describe("mcp tool table", () => { quote: "here", text: "why?", }); - expect(stub.agentComment).toHaveBeenCalledWith("vpr_t", { + expect(stub.agentComment).toHaveBeenCalledWith(ID, { anchor: "b1-aaaabbbb", quote: "here", text: "why?", @@ -127,7 +136,7 @@ describe("mcp tool table", () => { thread_id: "t1", text: "because", }); - expect(stub.agentReply).toHaveBeenCalledWith("vpr_t", { threadId: "t1", text: "because" }); + expect(stub.agentReply).toHaveBeenCalledWith(ID, { threadId: "t1", text: "because" }); }); it("maps join/leave onto presence RPCs", async () => { @@ -135,26 +144,26 @@ describe("mcp tool table", () => { agentJoin: vi.fn(async () => ({ ok: true })), agentLeave: vi.fn(async () => ({ ok: true })), }; - const deps = { getStub: async () => stub as never, token: "vpr_t" }; + const deps = { getStub: async () => stub as never, identity: ID }; await TOOLS.find((t) => t.name === "join")!.run(deps, { doc_id: "abcd1234", status: "drafting", }); - expect(stub.agentJoin).toHaveBeenCalledWith("vpr_t", "drafting"); + expect(stub.agentJoin).toHaveBeenCalledWith(ID, "drafting"); await TOOLS.find((t) => t.name === "leave")!.run(deps, { doc_id: "abcd1234" }); - expect(stub.agentLeave).toHaveBeenCalledWith("vpr_t"); + expect(stub.agentLeave).toHaveBeenCalledWith(ID); }); it("converts await_events since_cursor/timeout_s to RPC args", async () => { const stub = { agentAwaitEvents: vi.fn(async () => ({ events: [], cursor: 7 })) }; const tool = TOOLS.find((t) => t.name === "await_events")!; await tool.run( - { getStub: async () => stub as never, token: "vpr_t" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234", since_cursor: 7, timeout_s: 30 }, ); - expect(stub.agentAwaitEvents).toHaveBeenCalledWith("vpr_t", { + expect(stub.agentAwaitEvents).toHaveBeenCalledWith(ID, { cursor: 7, timeoutMs: 30_000, }); @@ -168,7 +177,7 @@ describe("mcp tool table", () => { }; const tool = TOOLS.find((t) => t.name === "read_document")!; const out = await tool.run( - { getStub: async () => stub as never, token: "nope" }, + { getStub: async () => stub as never, identity: ID }, { doc_id: "abcd1234" }, ); expect(out).toMatchObject({ error: { code: "invalid_token" } }); diff --git a/tests/unit/components/InviteAgentDialog.test.tsx b/tests/unit/components/InviteAgentDialog.test.tsx deleted file mode 100644 index dfb780c5..00000000 --- a/tests/unit/components/InviteAgentDialog.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createElement } from "react"; -import { fireEvent, waitFor, act } from "@testing-library/react"; -import { renderWithDocument } from "../../helpers/document-context"; -import InviteAgentDialog from "~/components/InviteAgentDialog"; - -const rosterEntry = { - name: "scribe", - color: "#E57373", - owner: null, - capabilities: ["suggest", "comment"], - createdAt: Date.now(), - lastSeenAt: null, -}; - -function mockFetchSequence(responses: Array<{ body: unknown; status?: number }>) { - const fn = vi.fn(); - for (const { body, status = 200 } of responses) { - fn.mockImplementationOnce( - () => - Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }), - ) as unknown as Promise, - ); - } - return fn; -} - -// jsdom has no ResizeObserver; @radix-ui/react-switch's useSize hook needs one -// for its Thumb. Real behaviour doesn't depend on actual measurements here. -class MockResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -describe("InviteAgentDialog", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - vi.stubGlobal("ResizeObserver", MockResizeObserver); - Object.assign(navigator, { - clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("opens with default capability switches: suggest+comment on, write off", async () => { - global.fetch = mockFetchSequence([{ body: [] }]); - - const { getByText, getByRole } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - fireEvent.click(getByText("Invite agent")); - - await waitFor(() => { - expect(getByRole("switch", { name: /suggest/i })).toBeTruthy(); - }); - - expect(getByRole("switch", { name: /suggest/i }).getAttribute("data-state")).toBe( - "checked", - ); - expect(getByRole("switch", { name: /comment/i }).getAttribute("data-state")).toBe( - "checked", - ); - expect(getByRole("switch", { name: /write/i }).getAttribute("data-state")).toBe( - "unchecked", - ); - }); - - it("shows inline validation error for an invalid name", async () => { - global.fetch = mockFetchSequence([{ body: [] }]); - - const { getByText, getByLabelText } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - fireEvent.click(getByText("Invite agent")); - await waitFor(() => getByLabelText("Name")); - - const nameInput = getByLabelText("Name") as HTMLInputElement; - fireEvent.change(nameInput, { target: { value: "Not Valid!" } }); - fireEvent.click(getByText("Create")); - - await waitFor(() => { - expect(getByText("Lowercase letters, digits, and hyphens")).toBeTruthy(); - }); - }); - - it("submits the typed name and shows the token screen once", async () => { - global.fetch = mockFetchSequence([ - { body: [] }, - { - body: { token: "secret-once-token", entry: rosterEntry }, - status: 201, - }, - { body: [rosterEntry] }, - ]); - - const { getByText, getByLabelText } = renderWithDocument( - createElement(InviteAgentDialog), - { context: { docId: "abcd1234" } }, - ); - - fireEvent.click(getByText("Invite agent")); - await waitFor(() => getByLabelText("Name")); - - const nameInput = getByLabelText("Name") as HTMLInputElement; - fireEvent.change(nameInput, { target: { value: "muse" } }); - - await act(async () => { - fireEvent.click(getByText("Create")); - }); - - await waitFor(() => { - expect(getByText("secret-once-token")).toBeTruthy(); - }); - - expect( - getByText("This token is shown once. Revoke and re-mint to replace it."), - ).toBeTruthy(); - - const postCall = (global.fetch as ReturnType).mock.calls.find( - ([, init]: [unknown, RequestInit | undefined]) => init?.method === "POST", - ); - expect(postCall).toBeTruthy(); - const [url, init] = postCall as [string, RequestInit]; - expect(url).toBe("/abcd1234/agents"); - const parsedBody = JSON.parse(init.body as string); - expect(parsedBody).toMatchObject({ intent: "mint", name: "muse" }); - }); - - it("exposes dialog role, aria-modal, and a labelled title", async () => { - global.fetch = mockFetchSequence([{ body: [] }]); - - const { getByText, getByRole } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - fireEvent.click(getByText("Invite agent")); - - const dialog = await waitFor(() => getByRole("dialog")); - expect(dialog.getAttribute("aria-modal")).toBe("true"); - - const labelledBy = dialog.getAttribute("aria-labelledby"); - expect(labelledBy).toBeTruthy(); - const title = document.getElementById(labelledBy as string); - expect(title?.textContent).toBe("Invite agent"); - }); - - it("focuses the name input on open", async () => { - global.fetch = mockFetchSequence([{ body: [] }]); - - const { getByText, getByLabelText } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - fireEvent.click(getByText("Invite agent")); - - await waitFor(() => { - expect(document.activeElement).toBe(getByLabelText("Name")); - }); - }); - - it("closes on Escape and returns focus to the invoking button", async () => { - global.fetch = mockFetchSequence([{ body: [] }]); - - const { getByText, getByRole, queryByRole } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - const trigger = getByText("Invite agent"); - fireEvent.click(trigger); - await waitFor(() => getByRole("dialog")); - - fireEvent.keyDown(document, { key: "Escape" }); - - await waitFor(() => { - expect(queryByRole("dialog")).toBeNull(); - }); - expect(document.activeElement).toBe(trigger); - }); - - it("closes on a backdrop click but not on a click inside the panel", async () => { - global.fetch = mockFetchSequence([{ body: [] }, { body: [] }]); - - const { getByText, getByRole, queryByRole } = renderWithDocument( - createElement(InviteAgentDialog), - ); - - fireEvent.click(getByText("Invite agent")); - const dialog = await waitFor(() => getByRole("dialog")); - - // A click that starts inside the panel must not close it. - fireEvent.click(dialog); - expect(getByRole("dialog")).toBeTruthy(); - - // A click on the overlay itself (the panel's parent) closes it. - const overlay = dialog.parentElement as HTMLElement; - fireEvent.click(overlay); - - await waitFor(() => { - expect(queryByRole("dialog")).toBeNull(); - }); - }); -}); diff --git a/tests/unit/components/SignIn.test.tsx b/tests/unit/components/SignIn.test.tsx new file mode 100644 index 00000000..ce4cfeb5 --- /dev/null +++ b/tests/unit/components/SignIn.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { createElement } from "react"; +import SignIn from "~/components/SignIn"; + +function mockFetch(routes: Record) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const key = `${init?.method ?? "GET"} ${new URL(url, "https://vapor.fyi").pathname}`; + const body = routes[key] ?? routes[new URL(url, "https://vapor.fyi").pathname] ?? {}; + return { ok: true, json: async () => body } as Response; + }); +} + +describe("SignIn", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch({ "/auth/me": { signedIn: false } })); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("renders a Sign in button when signed out", async () => { + render(createElement(SignIn)); + expect(await screen.findByText("Sign in")).toBeTruthy(); + }); + + it("shows the display name and Sign out when signed in", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ "/auth/me": { signedIn: true, displayName: "Nicholas" } }), + ); + render(createElement(SignIn)); + expect(await screen.findByText("Nicholas")).toBeTruthy(); + expect(screen.getByText("Sign out")).toBeTruthy(); + }); + + it("posts to /auth/logout on sign out", async () => { + const fetchMock = mockFetch({ + "/auth/me": { signedIn: true, displayName: "Nicholas" }, + "POST /auth/logout": { ok: true }, + }); + vi.stubGlobal("fetch", fetchMock); + render(createElement(SignIn)); + fireEvent.click(await screen.findByText("Sign out")); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/auth/logout", { method: "POST" }), + ); + }); +}); diff --git a/tests/unit/lib/agent-tokens.test.ts b/tests/unit/lib/agent-tokens.test.ts deleted file mode 100644 index 4a5feabf..00000000 --- a/tests/unit/lib/agent-tokens.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { generateAgentToken, hashToken } from "~/lib/agent-tokens"; - -describe("agent tokens", () => { - it("generates prefixed unique tokens", () => { - const t = generateAgentToken(); - expect(t).toMatch(/^vpr_[A-Za-z0-9_-]{43}$/); - expect(generateAgentToken()).not.toBe(t); - }); - it("hashes stably to 64 hex chars", async () => { - expect(await hashToken("vpr_x")).toBe(await hashToken("vpr_x")); - expect(await hashToken("vpr_x")).toMatch(/^[0-9a-f]{64}$/); - }); -}); diff --git a/tests/unit/routes/doc-agents-route.test.ts b/tests/unit/routes/doc-agents-route.test.ts index 40d33e9d..fb490e9b 100644 --- a/tests/unit/routes/doc-agents-route.test.ts +++ b/tests/unit/routes/doc-agents-route.test.ts @@ -4,17 +4,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; /* Mocks */ /* ------------------------------------------------------------------ */ -const { mockMint, mockRoster, mockRevoke } = vi.hoisted(() => ({ - mockMint: vi.fn(), +const { mockRoster, mockRevoke } = vi.hoisted(() => ({ mockRoster: vi.fn(), mockRevoke: vi.fn(), })); vi.mock("agents", () => ({ getAgentByName: vi.fn().mockResolvedValue({ - mintAgentToken: mockMint, getAgentRoster: mockRoster, - revokeAgentToken: mockRevoke, + revokeAgentEntry: mockRevoke, }), })); @@ -87,87 +85,37 @@ describe("GET /:id/agents (loader)", () => { describe("POST /:id/agents (action)", () => { it("returns 404 for an invalid document id", async () => { const response = (await action( - actionArgs("bad", { intent: "mint", name: "scribe" }), + actionArgs("bad", { intent: "revoke", name: "scribe" }), )) as Response; expect(response.status).toBe(404); }); - it("mints a token and returns it exactly once", async () => { - mockMint.mockResolvedValue({ token: "secret-token", entry: rosterEntry }); + it("returns 410 Gone for the retired mint intent", async () => { const response = (await action( actionArgs("abcd1234", { intent: "mint", name: "scribe" }), )) as Response; - - expect(response.status).toBe(201); - const json = await response.json(); - expect(json).toEqual({ token: "secret-token", entry: rosterEntry }); - expect(mockMint).toHaveBeenCalledWith({ - name: "scribe", - owner: undefined, - capabilities: undefined, - }); - }); - - it("passes owner and capabilities through to mintAgentToken", async () => { - mockMint.mockResolvedValue({ token: "t", entry: rosterEntry }); - await action( - actionArgs("abcd1234", { - intent: "mint", - name: "scribe", - owner: "nicholas", - capabilities: ["write"], - }), - ); - expect(mockMint).toHaveBeenCalledWith({ - name: "scribe", - owner: "nicholas", - capabilities: ["write"], - }); - }); - - it("rejects capabilities outside the known set", async () => { - const response = (await action( - actionArgs("abcd1234", { - intent: "mint", - name: "scribe", - capabilities: ["write", "admin"], - }), - )) as Response; - expect(response.status).toBe(400); - expect(mockMint).not.toHaveBeenCalled(); + expect(response.status).toBe(410); }); - it("returns 400 with the DO error for invalid_name", async () => { - mockMint.mockResolvedValue({ - error: { code: "invalid_name", message: "Agent name already taken: scribe" }, - }); + it("revokes an agent entry", async () => { + mockRevoke.mockResolvedValue({ ok: true }); const response = (await action( - actionArgs("abcd1234", { intent: "mint", name: "scribe" }), + actionArgs("abcd1234", { intent: "revoke", name: "scribe" }), )) as Response; - expect(response.status).toBe(400); + expect(response.status).toBe(200); const json = await response.json(); - expect(json.error.code).toBe("invalid_name"); + expect(json).toEqual({ ok: true }); + expect(mockRevoke).toHaveBeenCalledWith("scribe"); }); - it("returns 404 with the DO error for doc_not_found", async () => { - mockMint.mockResolvedValue({ + it("returns 404 with the DO error for doc_not_found on revoke", async () => { + mockRevoke.mockResolvedValue({ error: { code: "doc_not_found", message: "Document does not exist" }, }); - const response = (await action( - actionArgs("abcd1234", { intent: "mint", name: "scribe" }), - )) as Response; - expect(response.status).toBe(404); - }); - - it("revokes a token", async () => { - mockRevoke.mockResolvedValue({ ok: true }); const response = (await action( actionArgs("abcd1234", { intent: "revoke", name: "scribe" }), )) as Response; - expect(response.status).toBe(200); - const json = await response.json(); - expect(json).toEqual({ ok: true }); - expect(mockRevoke).toHaveBeenCalledWith("scribe"); + expect(response.status).toBe(404); }); it("returns 400 for an unknown intent", async () => { @@ -175,9 +123,8 @@ describe("POST /:id/agents (action)", () => { expect(response.status).toBe(400); }); - it("returns 400 for a missing name on mint", async () => { - const response = (await action(actionArgs("abcd1234", { intent: "mint" }))) as Response; + it("returns 400 for a missing name on revoke", async () => { + const response = (await action(actionArgs("abcd1234", { intent: "revoke" }))) as Response; expect(response.status).toBe(400); - expect(mockMint).not.toHaveBeenCalled(); }); }); diff --git a/workers/app.ts b/workers/app.ts index bcedf276..4f5f3d19 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -10,8 +10,8 @@ import { redirectLegacyDocPath, type MarkdownStub, } from "./routes"; -import { verifyGoogleIdToken } from "../app/lib/auth.server"; -import { handleOAuth } from "./oauth"; +import { verifyGoogleIdToken, verifySessionToken } from "../app/lib/auth.server"; +import { handleOAuth, OAUTH_CORS } from "./oauth"; import type Registry from "../agents/registry"; export { default as DocumentAgent } from "../agents/document"; @@ -24,6 +24,7 @@ const requestHandler = createRequestHandler( ); const mcpHandler = VaporMcp.serve("/mcp", { binding: "VaporMcp" }); +const anonMcpHandler = VaporMcp.serve("/mcp/anonymous", { binding: "VaporMcp" }); export default { async fetch(request, env, ctx) { @@ -98,12 +99,42 @@ export default { return markdownResponse; } - // The MCP server lives at /mcp (streamable HTTP). The bearer token rides - // along as props so the VaporMcp DO can pass it to DocumentAgent RPCs. + // The MCP server has two doors. /mcp/anonymous never challenges: + // tokenless sessions run as per-session anonymous identities. + if (url.pathname === "/mcp/anonymous" || url.pathname.startsWith("/mcp/anonymous/")) { + const props: VaporMcpProps = { auth: null, origin: url.origin }; + const mcpCtx: ExecutionContext = { + props, + waitUntil: (promise) => ctx.waitUntil(promise), + passThroughOnException: () => ctx.passThroughOnException(), + }; + return anonMcpHandler.fetch(request, env, mcpCtx); + } + + // /mcp is the identity door: it accepts exactly one credential type — a + // vapor OAuth access token (session JWT). A bare or invalid request gets + // the 401 challenge that drives MCP clients into the consent flow. if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { - const auth = request.headers.get("Authorization"); + const header = request.headers.get("Authorization"); + const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1] ?? null; + const claims = bearer + ? await verifySessionToken(bearer, env.SESSION_SECRET ?? "") + : null; + if (!claims) { + return new Response( + JSON.stringify({ error: "unauthorized", error_description: "OAuth access token required" }), + { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp"`, + ...OAUTH_CORS, + }, + }, + ); + } const props: VaporMcpProps = { - bearer: auth?.startsWith("Bearer ") ? auth.slice(7) : null, + auth: { principal: claims.principal, email: claims.email, caps: claims.caps }, origin: url.origin, }; // ExecutionContext.props is readonly, so hand the MCP handler its own diff --git a/workers/routes.ts b/workers/routes.ts index 7ff716be..9fe909b8 100644 --- a/workers/routes.ts +++ b/workers/routes.ts @@ -71,7 +71,7 @@ export function handleMcpHelp(request: Request): Response | null { if (request.method !== "GET") return null; const url = new URL(request.url); - if (url.pathname !== "/mcp") return null; + if (url.pathname !== "/mcp" && url.pathname !== "/mcp/anonymous") return null; const accept = request.headers.get("Accept") ?? ""; if (!accept.includes("text/html")) return null; From b37475a9ae2ab1ea423f7f52a1454ac5e667e9b1 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:17:11 -0700 Subject: [PATCH 49/83] Document the identity model and two MCP doors Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 25 +++++++++++++----- README.md | 16 +++++++----- app/lib/mcp-help.ts | 45 +++++++++++++-------------------- tests/unit/lib/mcp-help.test.ts | 5 ++-- 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7340953e..a8f3cf81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,13 +17,14 @@ Read project documents to load context: - `docs/design-system.md` — visual design, typography, colours, layout - `docs/technical-architecture.md` — platform, framework stack, directory structure, critical rules -- `docs/plans/2026-08-30-agent-collaborators-design.md` — agent collaborators spec (tokens, tool surface, performance engine) +- `docs/plans/2026-08-30-agent-collaborators-design.md` — agent collaborators spec (tool surface, performance engine) +- `docs/plans/2026-08-30-identity-design.md` — identity phase spec (Google sign-in, MCP OAuth, counterpart agents) Also check `plans/` for any active plan. ### Project Overview -vapor is a collaborative markdown editor — a cross between GitHub Gist and Google Docs. Users can quickly share and do multiplayer editing on markdown documents in real-time. Everything is public by URL (no auth yet). Documents persist live with no save button. Documents auto-expire after 99 hours. AI agents can join documents as human-like collaborators over MCP (see "Agent collaborators" below). +vapor is a collaborative markdown editor — a cross between GitHub Gist and Google Docs. Users can quickly share and do multiplayer editing on markdown documents in real-time. Everything is public by URL. Sign-in (Google) is optional and adds identity/attribution, never a wall. Documents persist live with no save button. Documents auto-expire after 99 hours. AI agents can join documents as human-like collaborators over MCP (see "Agent collaborators" below). Naming is "vapor" throughout: `APP_NAME`, page titles, the export frontmatter key (`vapor:`), and the theme localStorage key (`vapor-theme`). @@ -69,7 +70,7 @@ See `docs/technical-architecture.md` for full details. #### Directory Layout -- `agents/` — Server-side Durable Object agents: `DocumentAgent` (document state) and `VaporMcp` (MCP server) +- `agents/` — Server-side Durable Object agents: `DocumentAgent` (document state), `VaporMcp` (MCP server), `Registry` (global identity + OAuth state) - `app/components/` — React UI components - `app/lib/` — Editor logic, CriticMarkup, Yjs provider, utilities - `app/shared/` — Constants and types shared between client and server @@ -88,7 +89,10 @@ Documents render at the root path, not under `/docs`: | `/new` | `new.ts` | | `/:id` | `doc.$id.tsx` | | `/:id.md` | `workers/routes.ts` — raw markdown export | -| `/mcp` | `agents/mcp.ts` (`VaporMcp`) — MCP server | +| `/mcp` | `agents/mcp.ts` (`VaporMcp`) — OAuth-gated MCP server | +| `/mcp/anonymous` | `agents/mcp.ts` (`VaporMcp`) — tokenless MCP server | +| `/auth/*` | `workers/routes.ts` — Google sign-in sessions | +| `/oauth/*`, `/.well-known/oauth-*` | `workers/oauth.ts` — OAuth 2.1 AS for MCP | | `/agents/*` | `agents/document.ts` (`DocumentAgent`) — Yjs WebSocket | Root slugs share one namespace with a small reserved-word list (`app/shared/constants.ts`); the id generator and the `/:id` loader both guard against collisions. @@ -125,8 +129,17 @@ Track-changes functionality spans multiple files: AI agents connect as MCP clients and edit through the same CriticMarkup/Yjs machinery humans use, with a performance engine that paces their typing to look human. Full design: `docs/plans/2026-08-30-agent-collaborators-design.md`. - **`VaporMcp`** (`agents/mcp.ts`) — an `McpAgent` (Cloudflare Agents SDK) served at `/mcp`. Stateless per document: each tool call names a `doc_id` and forwards to that doc's `DocumentAgent` via DO-to-DO RPC. Tool schemas and definitions live in `agents/mcp-tools.ts`. -- **`DocumentAgent`** (extended) — owns the token roster, performance queue, and event log alongside the Yjs doc; all mutations happen inside the DO that owns the document. -- **`workers/routes.ts`** — pure (no `cloudflare:` imports) handlers for `GET /:id.md` (raw markdown) and the `GET /mcp` browser help page, wired into `workers/app.ts`. +- **`DocumentAgent`** (extended) — owns the agent roster, performance queue, and event log alongside the Yjs doc; all mutations happen inside the DO that owns the document. Agent RPCs take a verified `AgentIdentity` (principal or anonymous) and enroll it into the roster on first touch — there are no per-doc tokens. +- **`workers/routes.ts`** — pure (no `cloudflare:` imports) handlers for `GET /:id.md`, the MCP help page, and `/auth/*` sign-in, wired into `workers/app.ts`. + +#### Identity (Google sign-in + MCP OAuth) + +Ported from subpixel's dependency-free auth stack. Full design: `docs/plans/2026-08-30-identity-design.md`. + +- **`app/lib/auth.server.ts`** — Google ID-token verification (WebCrypto), HMAC session JWTs, the `vp_session` cookie. Identity is a principal (`email:`); sign-in is optional. +- **`agents/registry.ts`** (`Registry` DO, one `"global"` instance) — profiles, counterpart agent slugs, and OAuth clients/codes/refresh tokens. +- **`workers/oauth.ts`** — OAuth 2.1 AS (PKCE, dynamic registration, discovery). Access tokens are 1-hour session JWTs carrying the granted capabilities; the consent page (`app/lib/oauth-pages.ts`) is where write is granted. `/mcp` requires one of these; `/mcp/anonymous` needs none. +- Secrets: `SESSION_SECRET` (Workers secret), `GOOGLE_CLIENT_ID` (public var). See `.dev.vars.example`. #### Testing Constraints diff --git a/README.md b/README.md index 33d461c7..8fd297b8 100644 --- a/README.md +++ b/README.md @@ -99,23 +99,25 @@ Agents join a document as collaborators that look and behave like people: a name ### Connecting -No token needed to get started: +Two doors. **`https://vapor.fyi/mcp`** is the main one — signing in gives the agent a stable identity (its own counterpart, owned by you) and, if you grant it at consent, `write` access: ```bash claude mcp add --transport http vapor https://vapor.fyi/mcp ``` -The first tool call auto-enrolls an anonymous agent (`suggest` + `comment`) named after your MCP client, reused for the rest of the session. For claude.ai, add a custom connector at Settings → Connectors → Add custom connector, pointing at `https://vapor.fyi/mcp` — no header required. Any other MCP client works the same way over streamable HTTP. +Adding it runs an OAuth flow: the client opens a browser sign-in the first time, then remembers it. On claude.ai, add a custom connector at Settings → Connectors → Add custom connector pointing at the same URL — sign-in happens in the consent popup. -A token is only needed for `write` access or a stable identity across sessions. From the doc's **Invite agent** dialog, or directly: +Prefer no account? **`https://vapor.fyi/mcp/anonymous`** connects with zero setup and can `suggest` and `comment`: ```bash -claude mcp add --transport http vapor https://vapor.fyi/mcp --header "Authorization: Bearer " +claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous ``` -### Tokens +### Identity and capabilities -Tokens are minted per document from the **Invite agent** dialog — anyone who can open the doc can invite an agent, the same public-by-URL trust model as the rest of vapor. A new token defaults to `suggest` + `comment` capabilities; `write` (direct edits, no track-changes) is an explicit grant. The token is shown once at creation; revoke and re-mint if it's lost. +Sign-in (Google) is optional everywhere — anonymous editing, anonymous MCP, and public-by-URL documents are unchanged. What identity buys is attribution and a durable counterpart agent: presence and comments show your name, and your agent's roster entries are owned by you across every document. + +At consent you choose the agent's capabilities: **suggest + comment** (the default — tracked changes a human accepts or rejects) or **full write** (direct edits). Anonymous agents are always suggest + comment. Revoke an agent from a document via its **Agents** panel, or revoke the whole grant to sever the counterpart everywhere. ### Tools @@ -127,7 +129,7 @@ Tokens are minted per document from the **Invite agent** dialog — anyone who c - `reply` — reply in a thread - `join` / `leave` — enter/exit presence - `await_events` — long-poll for mentions, thread replies, doc-changed digests -- `create_document` — create a new doc and a token for it, no auth required +- `create_document` — create a new doc; the caller is enrolled as its first agent ### Raw export diff --git a/app/lib/mcp-help.ts b/app/lib/mcp-help.ts index b384d610..3070af84 100644 --- a/app/lib/mcp-help.ts +++ b/app/lib/mcp-help.ts @@ -21,17 +21,9 @@ const SAFE_ORIGIN_RE = /^https?:\/\/[a-z0-9.:[\]-]+$/i; export function mcpHelpHtml(origin: string): string { const safeOrigin = SAFE_ORIGIN_RE.test(origin) ? origin : DEFAULT_ORIGIN; const mcpUrl = `${safeOrigin}/mcp`; + const anonUrl = `${safeOrigin}/mcp/anonymous`; const mcpServersJson = JSON.stringify( - { - mcpServers: { - vapor: { - url: mcpUrl, - headers: { - Authorization: "Bearer ", - }, - }, - }, - }, + { mcpServers: { vapor: { url: mcpUrl } } }, null, 2, ); @@ -97,37 +89,34 @@ export function mcpHelpHtml(origin: string): string {

- Connecting works with no token at all: the first tool call auto-enrolls an - anonymous agent (suggest + comment) named after your client. A token is only - needed for direct-write access or a stable identity across sessions — mint - one from the document's Invite agent dialog, shown once, so - copy it right away. + ${mcpUrl} is the main door: signing in gives your agent a + stable identity and, if you grant it at consent, write access. Adding it in a + client pops a browser sign-in the first time. Prefer no account? + ${anonUrl} connects with zero setup and can suggest and + comment.

-

Claude Code

+

Claude Code — signed in

claude mcp add --transport http vapor ${mcpUrl}
-

With a token, for write access or a stable identity:

-
claude mcp add --transport http vapor ${mcpUrl} --header "Authorization: Bearer <token>"
+

Your client walks you through Google sign-in in the browser, then remembers it.

+ +

Claude Code — anonymous

+
claude mcp add --transport http vapor ${anonUrl}

claude.ai

- Go to Settings → Connectors → Add custom connector and paste this URL: + Go to Settings → Connectors → Add custom connector and paste the + main URL — sign-in happens in the consent popup:

${mcpUrl}
-

- That works tokenless. For write access or a stable identity, claude.ai will also - take an Authorization header — use Bearer <token> - with your document's token. -

Generic MCP client

${mcpServersJson}
-

Omit the headers block entirely to connect tokenless.

-

- Tokens are minted per document, from that document's Invite agent dialog — - there's no account or API key to set up separately. + Use ${anonUrl} for tokenless access; the main URL follows the OAuth + flow your client discovers automatically.

+ `; diff --git a/tests/unit/lib/mcp-help.test.ts b/tests/unit/lib/mcp-help.test.ts index 8e3a99bd..374c1ead 100644 --- a/tests/unit/lib/mcp-help.test.ts +++ b/tests/unit/lib/mcp-help.test.ts @@ -8,13 +8,12 @@ describe("mcpHelpHtml", () => { expect(html).toContain("claude mcp add"); }); - it("leads with a tokenless connection snippet, and keeps the token instructions too", () => { + it("offers both the signed-in and anonymous doors", () => { const html = mcpHelpHtml("https://vapor.fyi"); expect(html).toContain("claude mcp add --transport http vapor https://vapor.fyi/mcp"); expect(html).toContain( - 'claude mcp add --transport http vapor https://vapor.fyi/mcp --header "Authorization: Bearer <token>"', + "claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous", ); - expect(html).toContain("Invite agent"); }); it("never lets a hostile origin break out of its HTML context", () => { From a6dcb1b8c2465e29764ef105704073570c3801d2 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:31:05 -0700 Subject: [PATCH 50/83] Set GOOGLE_CLIENT_ID var for production sign-in Co-Authored-By: Claude Fable 5 --- wrangler.jsonc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wrangler.jsonc b/wrangler.jsonc index 1b876e2f..c490bfb3 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -25,5 +25,8 @@ { "tag": "v2", "new_sqlite_classes": ["VaporMcp"] }, { "tag": "v3", "new_sqlite_classes": ["Registry"] } ], + "vars": { + "GOOGLE_CLIENT_ID": "12054056676-thqs6nurgk15kjl9mtdju83r45nhigdd.apps.googleusercontent.com" + }, "keep_vars": true } From b0e87d42d443b4f4407bdcdec3d708e171cb297e Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:19:50 -0700 Subject: [PATCH 51/83] Add CIMD client support; fix sign-in presence, attribution, avatars, toolbar - OAuth server resolves a URL client_id as a Client ID Metadata Document (CIMD), so claude.ai's hosted-metadata connector works without DCR. - Shared reactive useSession replaces the one-shot /auth/me fetch, so signing in updates presence and comment attribution live (no reload). - On sign-in, the browser's anonymous comments in the doc are re-attributed to the signed-in identity; the anon id is retired. - Avatars: /auth/me returns the Google picture; rendered in the caret label, comment authors, and the header. - Sign-in is a flush toolbar button with a fixed-position popover, so it no longer overflows the horizontally-scrolling header. Co-Authored-By: Claude Fable 5 --- app/app.css | 18 +++++++ app/components/Editor.tsx | 8 +++- app/components/SignIn.tsx | 69 +++++++++++++-------------- app/components/ThreadPanel.tsx | 24 ++++++---- app/lib/thread-reattribution.ts | 46 ++++++++++++++++++ app/lib/useSession.ts | 54 +++++++++++++++++++++ app/lib/useYjsEditor.ts | 55 ++++++++++++--------- app/shared/types.ts | 2 + tests/unit/agents/oauth.test.ts | 65 ++++++++++++++++++++++++- tests/unit/components/SignIn.test.tsx | 8 ++-- workers/oauth.ts | 60 +++++++++++++++++++++-- workers/routes.ts | 5 +- 12 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 app/lib/thread-reattribution.ts create mode 100644 app/lib/useSession.ts diff --git a/app/app.css b/app/app.css index 96064004..a030be66 100644 --- a/app/app.css +++ b/app/app.css @@ -81,6 +81,24 @@ body { font-size: 0.9em; } +.tiptap .collaboration-cursor__avatar { + width: 1em; + height: 1em; + border-radius: 50%; + margin-right: 0.3em; + vertical-align: -0.15em; + object-fit: cover; +} + +/* Avatar / animal chip shown beside a comment author name. */ +.author-avatar { + width: 1.1em; + height: 1.1em; + border-radius: 50%; + object-fit: cover; + vertical-align: -0.2em; +} + .tiptap .collaboration-cursor__badge { margin-left: 0.3em; padding: 0 0.25em; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index ce3b00c9..3e9909f3 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -180,7 +180,13 @@ function renderCaret(user: Record) { const label = document.createElement("div"); label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); - if (user.animal) { + if (user.avatar) { + const avatar = document.createElement("img"); + avatar.classList.add("collaboration-cursor__avatar"); + avatar.setAttribute("src", user.avatar as string); + avatar.setAttribute("alt", ""); + label.insertBefore(avatar, null); + } else if (user.animal) { const animal = document.createElement("span"); animal.classList.add("anon-animal", "collaboration-cursor__animal"); animal.insertBefore(document.createTextNode(user.animal as string), null); diff --git a/app/components/SignIn.tsx b/app/components/SignIn.tsx index fb2ca8b7..8ba4e8b5 100644 --- a/app/components/SignIn.tsx +++ b/app/components/SignIn.tsx @@ -1,9 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -interface Session { - signedIn: boolean; - displayName?: string; -} +import { useEffect, useRef, useState } from "react"; +import { useSession, notifyAuthChanged } from "~/lib/useSession"; declare global { interface Window { @@ -20,33 +16,27 @@ declare global { /** * Header sign-in affordance. Signed out: a "Sign in" button that opens a - * popover and loads Google Identity Services on demand (never on every doc - * view). Signed in: the display name plus sign-out. Optional everywhere — - * anonymous users never see more than the button. + * popover and loads Google Identity Services on demand. Signed in: the + * avatar + display name plus sign-out. Optional everywhere. + * + * The button is a flush toolbar item (a direct sibling of the other header + * controls); the popover is fixed-position so it never widens the + * horizontally-scrolling header. */ export default function SignIn() { - const [session, setSession] = useState(null); + const session = useSession(); const [open, setOpen] = useState(false); const buttonHost = useRef(null); - const refresh = useCallback(() => { - fetch("/auth/me") - .then((r) => r.json()) - .then((s) => setSession(s as Session)) - .catch(() => setSession({ signedIn: false })); - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - // Load GSI and render the Google button only when the popover opens. useEffect(() => { if (!open || !buttonHost.current) return; let cancelled = false; async function mount() { - const config = (await fetch("/auth/config").then((r) => r.json())) as { googleClientId?: string }; + const config = (await fetch("/auth/config").then((r) => r.json())) as { + googleClientId?: string; + }; if (cancelled || !config.googleClientId) return; const render = () => { @@ -61,7 +51,7 @@ export default function SignIn() { }); if (res.ok) { setOpen(false); - refresh(); + notifyAuthChanged(); } }, }); @@ -82,37 +72,42 @@ export default function SignIn() { return () => { cancelled = true; }; - }, [open, refresh]); + }, [open]); async function signOut() { await fetch("/auth/logout", { method: "POST" }); - refresh(); + notifyAuthChanged(); } if (session?.signedIn) { return ( -
- {session.displayName} - -
+ ); } return ( -
+ <> {open && ( -
-
-
+ <> +
setOpen(false)} /> +
+
+
+ )} -
+ ); } diff --git a/app/components/ThreadPanel.tsx b/app/components/ThreadPanel.tsx index b7f0259e..9b27e269 100644 --- a/app/components/ThreadPanel.tsx +++ b/app/components/ThreadPanel.tsx @@ -70,10 +70,14 @@ export default function ThreadPanel({ > {/* Author + timestamp */}
- {thread.author.animal && ( - - {thread.author.animal} - + {thread.author.avatar ? ( + + ) : ( + thread.author.animal && ( + + {thread.author.animal} + + ) )} {thread.author.name} {timeAgo(thread.createdAt)} @@ -95,10 +99,14 @@ export default function ThreadPanel({ {thread.replies.map((reply) => (
- {reply.author.animal && ( - - {reply.author.animal} - + {reply.author.avatar ? ( + + ) : ( + reply.author.animal && ( + + {reply.author.animal} + + ) )} {reply.author.name} diff --git a/app/lib/thread-reattribution.ts b/app/lib/thread-reattribution.ts new file mode 100644 index 00000000..3684e783 --- /dev/null +++ b/app/lib/thread-reattribution.ts @@ -0,0 +1,46 @@ +import * as Y from "yjs"; +import type { ThreadData, UserInfo } from "~/shared/types"; + +/** + * Rewrites comment threads and replies this browser authored anonymously + * (author.id === formerId) to a newly signed-in identity, so a user's own + * earlier contributions stop showing an animal and carry their name/avatar. + * Best-effort and per-document: only the threads in this Y.Doc are touched. + */ +export function reattributeThreads(doc: Y.Doc, formerId: string, user: UserInfo): void { + const threadsMap = doc.getMap("threads"); + const author: UserInfo = { + name: user.name, + color: user.color, + colorLight: user.colorLight, + id: user.id, + ...(user.avatar ? { avatar: user.avatar } : {}), + }; + + doc.transact(() => { + threadsMap.forEach((raw, key) => { + let thread: ThreadData; + try { + thread = JSON.parse(raw) as ThreadData; + } catch { + return; + } + + let changed = false; + if (thread.author?.id === formerId) { + thread.author = { ...author }; + changed = true; + } + if (Array.isArray(thread.replies)) { + for (const reply of thread.replies) { + if (reply.author?.id === formerId) { + reply.author = { ...author }; + changed = true; + } + } + } + + if (changed) threadsMap.set(key, JSON.stringify(thread)); + }); + }); +} diff --git a/app/lib/useSession.ts b/app/lib/useSession.ts new file mode 100644 index 00000000..bc4a5f1e --- /dev/null +++ b/app/lib/useSession.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; + +export interface Session { + signedIn: boolean; + principal?: string; + email?: string; + displayName?: string; + agentSlug?: string | null; + avatar?: string | null; +} + +const AUTH_CHANGED = "vapor:auth-changed"; + +/** + * Notify every `useSession` in the tab that sign-in state changed, so + * presence, comments, and the header update immediately instead of waiting + * for a reload. SignIn calls this after login/logout. + */ +export function notifyAuthChanged() { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(AUTH_CHANGED)); + } +} + +/** + * Shared reactive view of `/auth/me`. Re-fetches when `notifyAuthChanged` + * fires, so signing in mid-session updates the whole page without a reload. + * Returns `null` until the first fetch resolves. + */ +export function useSession(): Session | null { + const [session, setSession] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = () => { + fetch("/auth/me") + .then((r) => r.json()) + .then((raw) => { + if (!cancelled) setSession(raw as Session); + }) + .catch(() => { + if (!cancelled) setSession({ signedIn: false }); + }); + }; + load(); + window.addEventListener(AUTH_CHANGED, load); + return () => { + cancelled = true; + window.removeEventListener(AUTH_CHANGED, load); + }; + }, []); + + return session; +} diff --git a/app/lib/useYjsEditor.ts b/app/lib/useYjsEditor.ts index f1cf3015..9ece096f 100644 --- a/app/lib/useYjsEditor.ts +++ b/app/lib/useYjsEditor.ts @@ -4,7 +4,9 @@ import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; import { YjsProvider } from "./yjs-provider"; import { USER_COLOURS } from "~/shared/constants"; -import { getAnonIdentity } from "./anon-identity"; +import { getAnonIdentity, retireAnonId } from "./anon-identity"; +import { useSession } from "./useSession"; +import { reattributeThreads } from "./thread-reattribution"; import type { UserInfo, DocMode } from "~/shared/types"; function anonUserInfo(): UserInfo { @@ -22,29 +24,38 @@ function anonUserInfo(): UserInfo { export function useYjsEditor(docId: string) { const doc = useMemo(() => new Y.Doc(), []); const awareness = useMemo(() => new Awareness(doc), [doc]); - const [user, setUser] = useState(anonUserInfo); + const anon = useMemo(() => anonUserInfo(), []); + const session = useSession(); - // If the viewer is signed in, present their real name instead of the - // anonymous animal. Sign-in is optional; anonymous users keep the animal. + // A signed-in viewer presents their real name and avatar; anonymous + // viewers keep the animal. Derived from the shared session so signing in + // mid-session updates presence and comment attribution without a reload. + const user = useMemo(() => { + if (session?.signedIn && session.displayName) { + return { + ...anon, + name: session.displayName, + id: session.principal ?? anon.id, + animal: undefined, + avatar: session.avatar ?? undefined, + }; + } + return anon; + }, [session, anon]); + + // Keep the awareness (presence) user in sync when it changes — e.g. on + // sign-in — so remote clients see the new name/avatar live. useEffect(() => { - let cancelled = false; - fetch("/auth/me") - .then((r) => r.json()) - .then((raw) => { - const s = raw as { signedIn?: boolean; displayName?: string; principal?: string }; - if (cancelled || !s.signedIn || !s.displayName) return; - setUser((prev) => ({ - ...prev, - name: s.displayName as string, - id: s.principal ?? prev.id, - animal: undefined, - })); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); + awareness.setLocalStateField("user", user); + }, [awareness, user]); + + // On sign-in, retire this browser's anonymous id and re-attribute the + // comments it authored in this document to the signed-in identity. + useEffect(() => { + if (!session?.signedIn || !anon.id || !user.id || user.id === anon.id) return; + reattributeThreads(doc, anon.id, user); + retireAnonId(); + }, [session, user, anon, doc]); const docState = useMemo(() => doc.getMap("docState"), [doc]); const providerRef = useRef(null); const [synced, setSynced] = useState(false); diff --git a/app/shared/types.ts b/app/shared/types.ts index 7f285146..80a7f767 100644 --- a/app/shared/types.ts +++ b/app/shared/types.ts @@ -6,6 +6,8 @@ export interface UserInfo { animal?: string; /** Stable identity key: the browser's anonymous uuid, or a principal after sign-in. */ id?: string; + /** Avatar image URL for a signed-in user (from Google), if any. */ + avatar?: string; } export type DocMode = "edit" | "suggest"; diff --git a/tests/unit/agents/oauth.test.ts b/tests/unit/agents/oauth.test.ts index c61aca44..3cef9692 100644 --- a/tests/unit/agents/oauth.test.ts +++ b/tests/unit/agents/oauth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { handleOAuth, type OAuthRegistry } from "../../../workers/oauth"; import { mintSessionToken, verifySessionToken, SESSION_COOKIE } from "../../../app/lib/auth.server"; import type { AuthCode, OAuthClient, RefreshGrant } from "../../../agents/registry"; @@ -319,6 +319,69 @@ describe("oauth authorization server", () => { ]); }); + it("accepts a CIMD url client_id by fetching its metadata document", async () => { + const registry = fakeRegistry(); + const metadataUrl = "https://claude.ai/.well-known/mcp-client"; + const stubbedFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as Request).url ?? input.toString(); + if (url === metadataUrl) { + return { + ok: true, + json: async () => ({ client_id: metadataUrl, client_name: "Claude", redirect_uris: [REDIRECT] }), + clone() { + return this as unknown as Response; + }, + } as unknown as Response; + } + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal("fetch", stubbedFetch); + try { + const { challenge } = await pkcePair(); + const res = await handleOAuth( + new Request( + `https://vapor.fyi/oauth/authorize?client_id=${encodeURIComponent(metadataUrl)}&redirect_uri=${encodeURIComponent(REDIRECT)}&response_type=code&code_challenge=${challenge}&code_challenge_method=S256`, + ), + deps(registry), + ); + // No stored registration needed: it reached the consent page (200), + // not the "unknown client_id" error (400). + expect(res?.status).toBe(200); + expect(stubbedFetch).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects a CIMD document whose redirect_uris don't cover the request", async () => { + const registry = fakeRegistry(); + const metadataUrl = "https://evil.example/meta"; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ redirect_uris: ["https://elsewhere.example/cb"] }), + clone() { + return this as unknown as Response; + }, + })), + ); + try { + const res = await handleOAuth( + new Request( + `https://vapor.fyi/oauth/authorize?client_id=${encodeURIComponent(metadataUrl)}&redirect_uri=${encodeURIComponent(REDIRECT)}&response_type=code`, + ), + deps(registry), + ); + // redirect_uri not in the document → treated as unknown client, 400, + // and it must NOT redirect. + expect(res?.status).toBe(400); + expect(res?.headers.get("Location")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("revoke always returns 200", async () => { const res = await handleOAuth( new Request("https://vapor.fyi/oauth/revoke", { diff --git a/tests/unit/components/SignIn.test.tsx b/tests/unit/components/SignIn.test.tsx index ce4cfeb5..477ca082 100644 --- a/tests/unit/components/SignIn.test.tsx +++ b/tests/unit/components/SignIn.test.tsx @@ -27,14 +27,14 @@ describe("SignIn", () => { expect(await screen.findByText("Sign in")).toBeTruthy(); }); - it("shows the display name and Sign out when signed in", async () => { + it("shows the display name when signed in, in a sign-out button", async () => { vi.stubGlobal( "fetch", mockFetch({ "/auth/me": { signedIn: true, displayName: "Nicholas" } }), ); render(createElement(SignIn)); - expect(await screen.findByText("Nicholas")).toBeTruthy(); - expect(screen.getByText("Sign out")).toBeTruthy(); + const btn = await screen.findByTitle("Sign out"); + expect(btn.textContent).toContain("Nicholas"); }); it("posts to /auth/logout on sign out", async () => { @@ -44,7 +44,7 @@ describe("SignIn", () => { }); vi.stubGlobal("fetch", fetchMock); render(createElement(SignIn)); - fireEvent.click(await screen.findByText("Sign out")); + fireEvent.click(await screen.findByTitle("Sign out")); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/auth/logout", { method: "POST" }), ); diff --git a/workers/oauth.ts b/workers/oauth.ts index b531af05..d82113fd 100644 --- a/workers/oauth.ts +++ b/workers/oauth.ts @@ -70,6 +70,59 @@ function oauthError(status: number, error: string, description: string): Respons return Response.json({ error, error_description: description }, { status }); } +/** + * Resolves a `client_id` to a client record. Two shapes are supported: + * - a DCR client id (opaque string) → looked up in the Registry; + * - a Client ID Metadata Document URL (CIMD, an https URL) → the document + * is fetched and its `redirect_uris`/`client_name` used directly, with + * no stored registration. This is what "Use Anthropic's hosted client + * metadata" needs. The document is cached via the Cache API. + * Returns null when the id is unknown or the metadata is unusable. + */ +async function resolveClient( + clientId: string, + deps: OAuthDeps, +): Promise { + if (!clientId) return null; + if (!/^https:\/\//i.test(clientId)) { + const { client } = await deps.registry.getClient(clientId); + return client; + } + + // CIMD: the client_id is itself the metadata URL. + const cache = typeof caches !== "undefined" ? (caches as CacheStorage & { default: Cache }).default : undefined; + const req = new Request(clientId, { headers: { Accept: "application/json" } }); + let res = await cache?.match(req); + if (!res) { + try { + res = await fetch(req); + } catch { + return null; + } + if (!res.ok) return null; + if (cache) await cache.put(req, res.clone()); + } + + let meta: Record; + try { + meta = (await res.json()) as Record; + } catch { + return null; + } + // The document must declare itself as this exact client_id and list valid + // redirect URIs — otherwise it can't be trusted to authorize a redirect. + if (typeof meta.client_id === "string" && meta.client_id !== clientId) return null; + const uris = meta.redirect_uris; + if (!Array.isArray(uris) || uris.length === 0 || !uris.every(validRedirectUri)) return null; + + return { + clientId, + name: typeof meta.client_name === "string" ? meta.client_name.slice(0, MAX_CLIENT_NAME) : clientId, + redirectUris: uris as string[], + createdAt: 0, + }; +} + function serverMetadata(origin: string) { return { issuer: origin, @@ -83,6 +136,9 @@ function serverMetadata(origin: string) { token_endpoint_auth_methods_supported: ["none"], scopes_supported: [], service_documentation: `${origin}/mcp`, + // Accept a Client ID Metadata Document URL as the client_id (CIMD), + // in addition to dynamically-registered ids. + client_id_metadata_document_supported: true, }; } @@ -142,9 +198,7 @@ async function handleAuthorize(request: Request, deps: OAuthDeps): Promise Promise<{ profile: { displayName: string; agentSlug: string | null } }>; + ) => Promise<{ profile: { displayName: string; agentSlug: string | null; avatar: string | null } }>; getProfile: ( principal: string, - ) => Promise<{ profile: { displayName: string; agentSlug: string | null } | null }>; + ) => Promise<{ profile: { displayName: string; agentSlug: string | null; avatar: string | null } | null }>; } function json(body: unknown, status = 200, headers: Record = {}): Response { @@ -167,6 +167,7 @@ export async function handleAuth(request: Request, deps: AuthDeps): Promise Date: Sun, 30 Aug 2026 19:23:41 -0700 Subject: [PATCH 52/83] Add /privacy and /terms pages Plain-language policies matching what vapor actually does: public-by-URL ephemeral documents, optional Google sign-in, anonymous localStorage identities, connectable agents, Cloudflare infrastructure. Linked from the home footer; slugs reserved. Co-Authored-By: Claude Fable 5 --- app/components/LegalPage.tsx | 55 ++++++++++++++++++++++ app/routes.ts | 2 + app/routes/home.tsx | 12 +++-- app/routes/privacy.tsx | 90 ++++++++++++++++++++++++++++++++++++ app/routes/terms.tsx | 81 ++++++++++++++++++++++++++++++++ app/shared/agent-protocol.ts | 2 + 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 app/components/LegalPage.tsx create mode 100644 app/routes/privacy.tsx create mode 100644 app/routes/terms.tsx diff --git a/app/components/LegalPage.tsx b/app/components/LegalPage.tsx new file mode 100644 index 00000000..2eac4662 --- /dev/null +++ b/app/components/LegalPage.tsx @@ -0,0 +1,55 @@ +import { Link } from "react-router"; +import type { ReactNode } from "react"; + +/** + * Shared shell for the /privacy and /terms pages: the vapor wordmark, a + * readable single column, and consistent heading treatment. + */ +export default function LegalPage({ + title, + updated, + children, +}: { + title: string; + updated: string; + children: ReactNode; +}) { + return ( +
+
+ + vapor + +
+ {title} +
+
+
+

{title}

+

Last updated {updated}

+ {children} +
+
+ + Privacy + + {" · "} + + Terms + + {" · "} + + GitHub + +
+
+ ); +} diff --git a/app/routes.ts b/app/routes.ts index 1bea35c9..dbf12025 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,6 +3,8 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("new", "routes/new.ts"), + route("privacy", "routes/privacy.tsx"), + route("terms", "routes/terms.tsx"), route(":id/agents", "routes/doc.$id.agents.ts"), route(":id", "routes/doc.$id.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 645ae2a3..4e221b47 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useCallback } from "react"; -import { useNavigate } from "react-router"; +import { useNavigate, Link } from "react-router"; import type { Route } from "./+types/home"; import { APP_NAME, generateDocumentId } from "~/shared/constants"; import { deserializeThreads } from "~/lib/thread-serialization"; @@ -174,8 +174,14 @@ export default function Home({ loaderData }: Route.ComponentProps) {
. - - MIT licensed + + + Privacy + + {" · "} + + Terms + diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx new file mode 100644 index 00000000..5e3343d0 --- /dev/null +++ b/app/routes/privacy.tsx @@ -0,0 +1,90 @@ +import LegalPage from "~/components/LegalPage"; +import type { Route } from "./+types/privacy"; + +export function meta(_args: Route.MetaArgs) { + return [{ title: "vapor — privacy" }]; +} + +export default function Privacy() { + return ( + +

+ vapor is a collaborative markdown editor operated by Artifact. This page describes what + vapor stores and why, in plain language. +

+ +

Documents are public and temporary

+

+ Every document is readable and editable by anyone who has its URL — there are no private + documents. Document content, comments, and tracked changes are stored on our + infrastructure only for the document's lifetime and are automatically and permanently + deleted about 99 hours after creation. Don't put anything in a document you wouldn't + share with everyone who might hold the link. +

+ +

Anonymous use

+

+ You can use vapor without an account. Anonymous visitors get a randomly generated + identity — an id, an animal, and a colour — stored only in your own browser's + localStorage. It's used to label your cursor and comments (for example "Anonymous + Otter") and is not tied to your name, email, or IP address by us. Clearing your browser + storage discards it. +

+ +

If you sign in

+

+ Sign-in is optional and uses Google. When you sign in we receive and store your email + address, display name, and avatar image URL from Google, and we set a session cookie + (vp_session) so you stay signed in. We use these only to attribute your + presence, comments, and agents to you. We never see or store your Google password, and + we don't post anything to your Google account. +

+ +

AI agents

+

+ vapor lets you connect AI agents (via the Model Context Protocol) that read and edit + documents. When you authorize an agent with your identity, we record the grant you chose + and the agent's activity is attributed to you in each document's agent roster. You can + revoke an agent from a document's Agents panel, or revoke the whole grant from your MCP + client. Anonymous agent connections are recorded per session, tied to nothing but that + session. +

+ +

What we don't do

+
    +
  • No advertising, and no selling or sharing of personal data.
  • +
  • No tracking cookies. The only cookie is the optional sign-in session.
  • +
  • + No training of AI models on your documents. Agents you connect see only what you point + them at, under the access you granted. +
  • +
+ +

Infrastructure

+

+ vapor runs on Cloudflare Workers, so requests pass through Cloudflare's network and are + subject to their standard operational logging. If analytics are enabled, we use Fathom, + a cookieless, privacy-focused analytics service that does not track individuals. +

+ +

Data removal

+

+ Documents remove themselves — everything in a document is permanently deleted when it + expires. To remove a signed-in profile (email, name, avatar) sooner, open an issue at{" "} + + github.com/arfct/vapor + {" "} + or contact Artifact, and we'll delete it. +

+ +

Changes

+

+ If this policy changes materially, we'll update this page and the date above. Continued + use after a change means you accept the updated policy. +

+
+ ); +} diff --git a/app/routes/terms.tsx b/app/routes/terms.tsx new file mode 100644 index 00000000..4a6bc31e --- /dev/null +++ b/app/routes/terms.tsx @@ -0,0 +1,81 @@ +import LegalPage from "~/components/LegalPage"; +import type { Route } from "./+types/terms"; + +export function meta(_args: Route.MetaArgs) { + return [{ title: "vapor — terms" }]; +} + +export default function Terms() { + return ( + +

+ vapor is a collaborative markdown editor operated by Artifact. By using vapor.fyi (and + its companion domains vpr.fyi and vaporware.fyi) you agree to these terms. They're + short, because the service is simple. +

+ +

What vapor is

+

+ vapor gives you ephemeral, multiplayer markdown documents. Every document is public to + anyone holding its URL, editable by anyone holding its URL, and automatically deleted + about 99 hours after creation. AI agents can join documents as collaborators when + someone connects them. +

+ +

Your content

+
    +
  • + You keep whatever rights you have in what you write. By putting content in a document + you grant vapor the permission needed to store, display, and sync it to other + participants for the document's lifetime. +
  • +
  • + You're responsible for what you post, and for having the right to post it. Don't post + other people's private information, malware, or content that's illegal where you or we + operate. +
  • +
  • + Documents are not private and not permanent. Don't use vapor to store secrets, + credentials, or anything you need to keep — export your markdown before it expires. +
  • +
+ +

Agents

+

+ If you connect an AI agent, its actions in a document are your responsibility, under the + capabilities you granted it. Rate limits apply to agents; attempts to evade them, flood + documents, or abuse the service may be blocked. +

+ +

Acceptable use

+

+ Don't attempt to disrupt the service, access others' data beyond what a document URL + already makes public, or use vapor to harass people or distribute spam. We may remove + content or block access to protect the service and its users. +

+ +

No warranty

+

+ vapor is a work in progress, provided as-is and as-available, without warranties of any + kind. Documents may be lost before their scheduled expiry; the service may change or be + discontinued. To the maximum extent permitted by law, Artifact is not liable for any + damages arising from your use of vapor, and our total liability is limited to the amount + you paid to use it — which is nothing, because it's free. +

+ +

Changes

+

+ We may update these terms; material changes will be reflected on this page with a new + date. Continued use after a change means you accept the updated terms. Questions or + problems:{" "} + + github.com/arfct/vapor + + . +

+
+ ); +} diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index 105dc30c..7620e317 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -75,6 +75,8 @@ export const RESERVED_SLUGS = [ "auth", "oauth", "settings", + "privacy", + "terms", ]; /** Whether a root slug is reserved (case-insensitive — URLs aren't). */ From 5ef93c952d55334b2c7aec15a5ca7a92b182459b Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:36:52 -0700 Subject: [PATCH 53/83] Vary anonymous identities beyond Anonymous with a persistent adjective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen adjectives (Anonymous, Cowardly, Mysterious, Bashful, Curious, Sleepy, Dapper, Skeptical, Wandering, Punctual, Suspicious, Heroic, Melodramatic) pair with the animal — assigned once per browser like the animal and colour. Pre-adjective stored identities are migrated in place. Co-Authored-By: Claude Fable 5 --- app/lib/anon-identity.ts | 12 +++++++++++- app/lib/useYjsEditor.ts | 2 +- app/shared/anon-animals.ts | 21 +++++++++++++++++++++ tests/unit/lib/anon-identity.test.ts | 16 +++++++++++++++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/lib/anon-identity.ts b/app/lib/anon-identity.ts index 37e606f4..8c3e943b 100644 --- a/app/lib/anon-identity.ts +++ b/app/lib/anon-identity.ts @@ -1,4 +1,4 @@ -import { ANON_ANIMALS } from "~/shared/anon-animals"; +import { ANON_ANIMALS, ANON_ADJECTIVES } from "~/shared/anon-animals"; import { USER_COLOURS } from "~/shared/constants"; import type { AnonAnimal } from "~/shared/anon-animals"; @@ -7,6 +7,7 @@ const FORMER_KEY = "vapor-former-anon-id"; export interface AnonIdentity { id: string; + adjective: string; animal: AnonAnimal; colorIndex: number; } @@ -15,6 +16,8 @@ interface StoredAnon { id: string; animalIndex: number; colorIndex: number; + /** Absent in identities stored before adjectives existed. */ + adjectiveIndex?: number; } function randomIndex(bound: number): number { @@ -24,6 +27,7 @@ function randomIndex(bound: number): number { function toIdentity(stored: StoredAnon): AnonIdentity { return { id: stored.id, + adjective: ANON_ADJECTIVES[(stored.adjectiveIndex ?? 0) % ANON_ADJECTIVES.length], animal: ANON_ANIMALS[stored.animalIndex % ANON_ANIMALS.length], colorIndex: stored.colorIndex % USER_COLOURS.length, }; @@ -43,6 +47,7 @@ export function getAnonIdentity(): AnonIdentity { : `anon-${Date.now()}-${randomIndex(1_000_000)}`, animalIndex: randomIndex(ANON_ANIMALS.length), colorIndex: randomIndex(USER_COLOURS.length), + adjectiveIndex: randomIndex(ANON_ADJECTIVES.length), }; try { @@ -54,6 +59,11 @@ export function getAnonIdentity(): AnonIdentity { typeof parsed.animalIndex === "number" && typeof parsed.colorIndex === "number" ) { + // Identities stored before adjectives existed get one now, once. + if (typeof parsed.adjectiveIndex !== "number") { + parsed.adjectiveIndex = randomIndex(ANON_ADJECTIVES.length); + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + } return toIdentity(parsed as StoredAnon); } } diff --git a/app/lib/useYjsEditor.ts b/app/lib/useYjsEditor.ts index 9ece096f..fb9caef7 100644 --- a/app/lib/useYjsEditor.ts +++ b/app/lib/useYjsEditor.ts @@ -13,7 +13,7 @@ function anonUserInfo(): UserInfo { const anon = getAnonIdentity(); const c = USER_COLOURS[anon.colorIndex]; return { - name: `Anonymous ${anon.animal.name}`, + name: `${anon.adjective} ${anon.animal.name}`, color: c.color, colorLight: c.light, animal: anon.animal.glyph, diff --git a/app/shared/anon-animals.ts b/app/shared/anon-animals.ts index c530cf4f..cbf25880 100644 --- a/app/shared/anon-animals.ts +++ b/app/shared/anon-animals.ts @@ -10,6 +10,27 @@ export interface AnonAnimal { name: string; } +/** + * Adjectives paired with the animal — "Anonymous" is just one of the + * collection, so a visitor might be a Cowardly Lion or a Mysterious + * Octopus. Assigned once per browser, like the animal and colour. + */ +export const ANON_ADJECTIVES: readonly string[] = [ + "Anonymous", + "Cowardly", + "Mysterious", + "Bashful", + "Curious", + "Sleepy", + "Dapper", + "Skeptical", + "Wandering", + "Punctual", + "Suspicious", + "Heroic", + "Melodramatic", +] as const; + export const ANON_ANIMALS: readonly AnonAnimal[] = [ { glyph: "🐙", name: "Octopus" }, { glyph: "🦊", name: "Fox" }, diff --git a/tests/unit/lib/anon-identity.test.ts b/tests/unit/lib/anon-identity.test.ts index b04dc178..c8e158b1 100644 --- a/tests/unit/lib/anon-identity.test.ts +++ b/tests/unit/lib/anon-identity.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach } from "vitest"; import { getAnonIdentity, retireAnonId, formerAnonId } from "~/lib/anon-identity"; -import { ANON_ANIMALS } from "~/shared/anon-animals"; +import { ANON_ANIMALS, ANON_ADJECTIVES } from "~/shared/anon-animals"; import { USER_COLOURS } from "~/shared/constants"; describe("anon identity", () => { @@ -15,11 +15,25 @@ describe("anon identity", () => { expect(second.id).toBe(first.id); expect(second.animal.glyph).toBe(first.animal.glyph); expect(second.colorIndex).toBe(first.colorIndex); + expect(second.adjective).toBe(first.adjective); + expect(ANON_ADJECTIVES).toContain(first.adjective); expect(ANON_ANIMALS.map((a) => a.glyph)).toContain(first.animal.glyph); expect(first.colorIndex).toBeGreaterThanOrEqual(0); expect(first.colorIndex).toBeLessThan(USER_COLOURS.length); }); + it("assigns an adjective to a pre-adjective stored identity, once", () => { + localStorage.setItem( + "vapor-anon", + JSON.stringify({ id: "legacy-id", animalIndex: 1, colorIndex: 2 }), + ); + const first = getAnonIdentity(); + expect(first.id).toBe("legacy-id"); + expect(ANON_ADJECTIVES).toContain(first.adjective); + const second = getAnonIdentity(); + expect(second.adjective).toBe(first.adjective); + }); + it("survives corrupt storage by regenerating", () => { localStorage.setItem("vapor-anon", "{not json"); const identity = getAnonIdentity(); From 65496d931986131ff5a49c0942a162c6bf4ae366 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:43:45 -0700 Subject: [PATCH 54/83] Fix duplicate comment threads; attribute agents as their owner's Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment reconciliation treated marks as ground truth and let every connected client create a thread for a new mark — one comment became N threads, each stamped with a bystander's identity. Now only the authoring client creates immediately; others schedule a 3s fallback (covering imported comments) and all paths use a deterministic thread id so stragglers converge on one Y.Map key. Counterpart agents gain a display label ("'s Agent") shown in presence, comments, and the roster; the slug remains the @mention handle. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 28 +++++----- agents/mcp.ts | 7 ++- app/components/AgentsPanel.tsx | 5 +- app/lib/useThreads.ts | 72 +++++++++++++++++++++++--- app/shared/agent-protocol.ts | 6 ++- tests/unit/lib/comment-threads.test.ts | 12 +++++ 6 files changed, 109 insertions(+), 21 deletions(-) diff --git a/agents/document.ts b/agents/document.ts index 533e06ba..49e92d01 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -84,6 +84,7 @@ interface PerformanceRow { interface RosterRow { identity_id: string; name: string; + label?: string | null; color: string; owner: string | null; capabilities: string; @@ -102,6 +103,7 @@ interface MutationLogEntry { function rowToRosterEntry(row: RosterRow): AgentRosterEntry { return { name: row.name, + label: row.label ?? null, color: row.color, owner: row.owner, capabilities: JSON.parse(row.capabilities) as AgentCapability[], @@ -166,6 +168,7 @@ class DocumentAgent extends Agent { CREATE TABLE IF NOT EXISTS roster ( identity_id TEXT PRIMARY KEY, name TEXT UNIQUE, + label TEXT, color TEXT, owner TEXT, capabilities TEXT, @@ -626,12 +629,13 @@ class DocumentAgent extends Agent { const color = USER_COLOURS[roster.length % USER_COLOURS.length].color; const createdAt = Date.now(); this.sql` - INSERT INTO roster (identity_id, name, color, owner, capabilities, created_at, last_seen_at) - VALUES (${identity.id}, ${name}, ${color}, ${identity.owner}, ${JSON.stringify(identity.caps)}, ${createdAt}, ${null}) + INSERT INTO roster (identity_id, name, label, color, owner, capabilities, created_at, last_seen_at) + VALUES (${identity.id}, ${name}, ${identity.label ?? null}, ${color}, ${identity.owner}, ${JSON.stringify(identity.caps)}, ${createdAt}, ${null}) `; return { entry: { name, + label: identity.label ?? null, color, owner: identity.owner, capabilities: identity.caps, @@ -925,7 +929,7 @@ class DocumentAgent extends Agent { const roster = await this.getAgentRoster(); for (const entry of roster) { if (entry.lastSeenAt != null && now - entry.lastSeenAt < 5 * 60 * 1000) { - presence.push({ name: entry.name, isAgent: true }); + presence.push({ name: entry.label ?? entry.name, isAgent: true }); } } @@ -1070,13 +1074,13 @@ class DocumentAgent extends Agent { return { error: { code: resolved.error, message: "Anchor not found", snippet: resolved.snippet } }; } - const { name, color } = verified.entry; + const { name, label, color } = verified.entry; const id = crypto.randomUUID(); const thread: ThreadData = { id, commentText: args.text, highlightText: args.quote, - author: { name, color, colorLight: color }, + author: { name: label ?? name, color, colorLight: color }, createdAt: Date.now(), resolved: false, replies: [], @@ -1122,10 +1126,10 @@ class DocumentAgent extends Agent { return { error: { code: "thread_not_found", message: "thread is unreadable" } }; } - const { name, color } = verified.entry; + const { name, label, color } = verified.entry; const reply: ThreadReply = { id: crypto.randomUUID(), - author: { name, color, colorLight: color }, + author: { name: label ?? name, color, colorLight: color }, text: args.text, createdAt: Date.now(), }; @@ -1147,9 +1151,9 @@ class DocumentAgent extends Agent { const verified = await this.verifyIdentity(identity); if ("error" in verified) return verified; - const { name, color } = verified.entry; + const { name, label, color } = verified.entry; this.setAgentPresence(name, { - user: { name, color, isAgent: true }, + user: { name: label ?? name, color, isAgent: true }, ...(status !== undefined ? { status } : {}), }); this.resetAgentIdleTimer(name); @@ -1540,11 +1544,11 @@ class DocumentAgent extends Agent { const status = existing?.state?.status; let user = existing?.state?.user; if (!user) { - const rows = this.sql<{ name: string; color: string }>` - SELECT name, color FROM roster WHERE name = ${agentName} + const rows = this.sql<{ name: string; label: string | null; color: string }>` + SELECT name, label, color FROM roster WHERE name = ${agentName} `; if (rows.length === 0) return; // unknown agent — nothing sane to show - user = { name: rows[0].name, color: rows[0].color, isAgent: true }; + user = { name: rows[0].label ?? rows[0].name, color: rows[0].color, isAgent: true }; } this.setAgentPresence(agentName, { user, ...(status !== undefined ? { status } : {}), cursor }); diff --git a/agents/mcp.ts b/agents/mcp.ts index bd6a4481..769683ec 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -48,8 +48,9 @@ function jsonContent(result: unknown) { export class VaporMcp extends McpAgent, VaporMcpProps> { server = new McpServer({ name: "vapor", version: "1.0.0" }); - /** Session-cached counterpart slug for the principal path. */ + /** Session-cached counterpart slug + label for the principal path. */ private agentSlug: string | null = null; + private agentLabel: string | null = null; /** * The identity every tool call runs under. Principals get their global @@ -64,11 +65,15 @@ export class VaporMcp extends McpAgent, VaporMcpProps const ensured = await registry.ensureAgentSlug(auth.principal); this.agentSlug = "slug" in ensured ? ensured.slug : slugifyAgentName(auth.email.split("@")[0] ?? "agent"); + const { profile } = await registry.getProfile(auth.principal); + const ownerName = profile?.displayName ?? auth.email.split("@")[0] ?? "Someone"; + this.agentLabel = `${ownerName}'s Agent`; } return { kind: "principal", id: auth.principal, name: this.agentSlug, + label: this.agentLabel ?? undefined, owner: auth.principal, caps: auth.caps ?? [...DEFAULT_CAPABILITIES], }; diff --git a/app/components/AgentsPanel.tsx b/app/components/AgentsPanel.tsx index d2558c69..55dda541 100644 --- a/app/components/AgentsPanel.tsx +++ b/app/components/AgentsPanel.tsx @@ -157,7 +157,10 @@ export default function AgentsPanel() { className="inline-block h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} /> - {entry.name} + {entry.label ?? entry.name} + {entry.label && ( + @{entry.name} + )} {entry.capabilities.map((c) => ( void) => void }): ThreadData[] { const threads: ThreadData[] = []; map.forEach((val) => { @@ -37,6 +47,8 @@ export function useThreads({ const threadsMapRef = useRef(doc.getMap("threads")); const pendingActivateRef = useRef(null); const reconcilingRef = useRef(false); + /** Delayed fallback creations for marks whose author hasn't written a thread yet. */ + const fallbackTimersRef = useRef(new Map>()); const suppressSelectionRef = useRef(false); // Reconcile: scan document marks, auto-create Y.Map entries for new comments, @@ -60,13 +72,50 @@ export function useThreads({ } } - // Auto-create threads for unmatched comments (document marks are ground truth) + // Auto-create threads for unmatched comments (document marks are ground + // truth) — but only the client that AUTHORED the comment creates its + // thread immediately. Marks sync to every connected client within + // milliseconds, and when each of them "reconciled" instantly, one + // comment became N threads, each stamped with a bystander's identity. + // Non-authors instead schedule a delayed fallback (covering imported + // {>>comments<<} whose author isn't present), and every creation path + // uses a deterministic id so stragglers converge on one Y.Map key. let created = false; for (let i = 0; i < comments.length; i++) { if (usedCommentIndices.has(i)) continue; const comment = comments[i]; - const id = generateId(); + const id = threadIdForComment(comment); + const isAuthor = pendingActivateRef.current === comment.commentText; + + if (!isAuthor) { + if (!fallbackTimersRef.current.has(id)) { + const timer = setTimeout(() => { + fallbackTimersRef.current.delete(id); + // Re-check: skip if the author's (or anyone's) thread arrived. + if (threadsMapRef.current.get(id) !== undefined) return; + const existing = readAllThreads(threadsMapRef.current); + if (existing.some((t) => t.commentText === comment.commentText)) return; + reconcilingRef.current = true; + threadsMapRef.current.set( + id, + JSON.stringify({ + id, + commentText: comment.commentText, + highlightText: comment.highlightText, + author: user, + createdAt: Date.now(), + resolved: false, + replies: [], + } satisfies ThreadData), + ); + reconcilingRef.current = false; + }, 3000); + fallbackTimersRef.current.set(id, timer); + } + continue; + } + const thread: ThreadData = { id, commentText: comment.commentText, @@ -81,11 +130,13 @@ export function useThreads({ threadsMapRef.current.set(id, JSON.stringify(thread)); reconcilingRef.current = false; created = true; + setActiveThreadId(id); + pendingActivateRef.current = null; - // If this was a comment just inserted via CommentInput, activate it - if (pendingActivateRef.current === comment.commentText) { - setActiveThreadId(id); - pendingActivateRef.current = null; + const pendingTimer = fallbackTimersRef.current.get(id); + if (pendingTimer) { + clearTimeout(pendingTimer); + fallbackTimersRef.current.delete(id); } } @@ -109,6 +160,15 @@ export function useThreads({ } }, [editor, user]); + // Cancel any pending fallback creations when the hook unmounts. + useEffect(() => { + const timers = fallbackTimersRef.current; + return () => { + timers.forEach((t) => clearTimeout(t)); + timers.clear(); + }; + }, []); + // Observe Y.Map changes (from remote clients) useEffect(() => { const map = threadsMapRef.current; diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index 7620e317..34a43cfa 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -3,6 +3,8 @@ export type Pace = "natural" | "fast" | "instant"; export interface AgentRosterEntry { name: string; // slug, unique per doc + /** Display attribution ("'s Agent"); null for anonymous agents. */ + label?: string | null; color: string; // one of USER_COLOURS .color values owner: string | null; // free text this phase capabilities: AgentCapability[]; @@ -29,7 +31,9 @@ export interface DocBlock extends BlockAnchor { export interface AgentIdentity { kind: "principal" | "anonymous"; id: string; // principal ("email:…") or anonymous session key - name: string; // roster/display slug (agentSlug or slugified clientInfo) + name: string; // roster slug (agentSlug or slugified clientInfo) — used for @mentions + /** Human-facing attribution, e.g. "Nicholas Jitkoff's Agent". Falls back to name. */ + label?: string; owner: string | null; // principal for kind=principal, null for anonymous caps: AgentCapability[]; } diff --git a/tests/unit/lib/comment-threads.test.ts b/tests/unit/lib/comment-threads.test.ts index 56a6e498..3eb93621 100644 --- a/tests/unit/lib/comment-threads.test.ts +++ b/tests/unit/lib/comment-threads.test.ts @@ -99,3 +99,15 @@ describe("findOrphanedThreads", () => { expect(orphans).toHaveLength(2); }); }); + +describe("threadIdForComment (duplicate-thread regression)", () => { + it("is deterministic across clients for the same comment mark", async () => { + const { threadIdForComment } = await import("~/lib/useThreads"); + const a = threadIdForComment({ commentText: "hi!", highlightText: "visiting" }); + const b = threadIdForComment({ commentText: "hi!", highlightText: "visiting" }); + expect(a).toBe(b); + expect(a).toMatch(/^t-[0-9a-f]{8}$/); + // Different comments get different keys. + expect(threadIdForComment({ commentText: "nice", highlightText: "fled" })).not.toBe(a); + }); +}); From cd5b205d43532395d4902db1fed3167a59d81cf4 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:48:47 -0700 Subject: [PATCH 55/83] Label anonymous agents Agentic The animal is picked deterministically from the MCP session key, so a session is the same creature everywhere; the clientInfo slug remains the @mention handle. Co-Authored-By: Claude Fable 5 --- agents/mcp-tools.ts | 13 ++++++++++++- agents/mcp.ts | 5 ++++- tests/unit/agents/mcp-tools.test.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index a1bd5f8e..39826780 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -8,7 +8,8 @@ */ import { z } from "zod"; import { isValidDocumentId } from "../app/shared/constants"; -import { slugifyAgentName, type AgentError, type AgentIdentity } from "../app/shared/agent-protocol"; +import { slugifyAgentName, blockHash, type AgentError, type AgentIdentity } from "../app/shared/agent-protocol"; +import { ANON_ANIMALS } from "../app/shared/anon-animals"; /** The subset of the DocumentAgent RPC surface the tools call. */ export interface DocStub { @@ -81,6 +82,16 @@ export function createDocumentAgentName(clientName: string | undefined): string return slugifyAgentName(clientName ?? "agent"); } +/** + * Display label for an anonymous agent: "Agentic ", with the animal + * picked deterministically from the MCP session key so the same session is + * the same creature in every document and on every call. + */ +export function anonymousAgentLabel(sessionKey: string): string { + const index = parseInt(blockHash(sessionKey), 16) % ANON_ANIMALS.length; + return `Agentic ${ANON_ANIMALS[index].name}`; +} + const docId = z.string().describe("The 8-character document id (from its URL)."); const pace = z .enum(["natural", "fast", "instant"]) diff --git a/agents/mcp.ts b/agents/mcp.ts index 769683ec..bfa5f611 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -19,6 +19,7 @@ import { TOOLS, validateNewDocumentMarkdown, createDocumentAgentName, + anonymousAgentLabel, type DocStub, } from "./mcp-tools"; import { generateDocumentId } from "../app/shared/constants"; @@ -80,12 +81,14 @@ export class VaporMcp extends McpAgent, VaporMcpProps } const clientInfo = this.server.server.getClientVersion(); + const sessionKey = `anon:${this.name}`; return { kind: "anonymous", // this.name is the per-session DO instance name (stable across // reconnects of the same MCP session). - id: `anon:${this.name}`, + id: sessionKey, name: slugifyAgentName(clientInfo?.name ?? "agent"), + label: anonymousAgentLabel(sessionKey), owner: null, caps: [...DEFAULT_CAPABILITIES], }; diff --git a/tests/unit/agents/mcp-tools.test.ts b/tests/unit/agents/mcp-tools.test.ts index 3547d804..f04a6264 100644 --- a/tests/unit/agents/mcp-tools.test.ts +++ b/tests/unit/agents/mcp-tools.test.ts @@ -216,3 +216,15 @@ describe("createDocumentAgentName", () => { expect(createDocumentAgentName("!!!")).toBe("agent"); }); }); + +describe("anonymousAgentLabel", () => { + it("is a stable Agentic per session key", async () => { + const { anonymousAgentLabel } = await import("../../../agents/mcp-tools"); + const { ANON_ANIMALS } = await import("../../../app/shared/anon-animals"); + const a = anonymousAgentLabel("anon:streamable-http:abc123"); + expect(a).toBe(anonymousAgentLabel("anon:streamable-http:abc123")); + expect(a).toMatch(/^Agentic /); + expect(ANON_ANIMALS.map((x) => `Agentic ${x.name}`)).toContain(a); + expect(anonymousAgentLabel("anon:other-session")).toMatch(/^Agentic /); + }); +}); From c84e2c342a82d0fd7d98a2c87f19d1288c23f4d2 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:52:39 -0700 Subject: [PATCH 56/83] Use the owner's first name for signed-in agent labels "Nicholas's Agent" rather than the full display name; existing roster rows pick up label changes on their next enrollment touch. Co-Authored-By: Claude Fable 5 --- agents/document.ts | 5 +++++ agents/mcp.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/agents/document.ts b/agents/document.ts index 49e92d01..b0f27c49 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -601,6 +601,11 @@ class DocumentAgent extends Agent { this.sql`UPDATE roster SET capabilities = ${caps} WHERE identity_id = ${identity.id}`; row.capabilities = caps; } + const label = identity.label ?? null; + if (label !== null && (row.label ?? null) !== label) { + this.sql`UPDATE roster SET label = ${label} WHERE identity_id = ${identity.id}`; + row.label = label; + } return { entry: rowToRosterEntry(row) }; } diff --git a/agents/mcp.ts b/agents/mcp.ts index bfa5f611..bd972a10 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -68,7 +68,9 @@ export class VaporMcp extends McpAgent, VaporMcpProps "slug" in ensured ? ensured.slug : slugifyAgentName(auth.email.split("@")[0] ?? "agent"); const { profile } = await registry.getProfile(auth.principal); const ownerName = profile?.displayName ?? auth.email.split("@")[0] ?? "Someone"; - this.agentLabel = `${ownerName}'s Agent`; + // First name only: "Nicholas's Agent", not the full display name. + const firstName = ownerName.trim().split(/\s+/)[0] || "Someone"; + this.agentLabel = `${firstName}'s Agent`; } return { kind: "principal", From 94755feb17a5861902a53f04bbea375104eba451 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:16:27 -0700 Subject: [PATCH 57/83] Rewrite README around what vapor is now Leads with using it, the identity lineup, and the two agent doors; architecture and development follow. Replaces the accreted structure. Co-Authored-By: Claude Fable 5 --- README.md | 160 +++++++++++++++++++++--------------------------------- 1 file changed, 61 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 8fd297b8..5d63a807 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,106 @@ # vapor -Collaborative markdown editor. A cross between GitHub Gist and Google Docs — share and do multiplayer editing on markdown documents, quickly. +**[vapor.fyi](https://vapor.fyi)** — ephemeral, multiplayer markdown documents that people and AI agents edit together. -vapor is a fork of [mist](https://github.com/inanimate-tech/mist). +A cross between GitHub Gist and Google Docs: paste a URL at someone and you're co-writing, live cursors and all. Then invite an agent, and it joins the same document the same way — a name, a colour, a cursor, human-paced typing, tracked-change suggestions, and comments. -Everything is public by URL. Documents persist live with no save button. Multiple users (and AI agents — see below) see each other's cursors in real time. +Every document is public to anyone holding its URL, saves itself continuously, and deletes itself about 99 hours after creation. vapor is a fork of [mist](https://github.com/inanimate-tech/mist). -## Features +## Using it -- **Real-time multiplayer editing** via TipTap + Yjs, backed by Cloudflare Durable Objects -- **Live markdown formatting** — inline styles render as you type, with formatting characters shown in grey -- **Suggest mode** — track changes using CriticMarkup (additions, deletions, comments, highlights) -- **Threaded comments** with highlight anchoring -- **Preview mode** — rendered markdown with click, hover, or keypress toggle -- **AI agent collaborators** — invite Claude Code or any MCP client into a document as a named collaborator -- **CLI upload** — `curl https://your-domain/new -T file.md` -- **Drag and drop** `.md` files to create new documents -- **Dark/light/auto themes** -- **Documents auto-expire** after 99 hours +Go to [vapor.fyi](https://vapor.fyi) and start typing, or: -## Tech stack +```bash +# create a document from a file +curl https://vapor.fyi/new -T notes.md -- [Cloudflare Workers](https://developers.cloudflare.com/workers/) + [Durable Objects](https://developers.cloudflare.com/durable-objects/) (backend + persistence) -- [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) (real-time WebSocket agent, MCP server) -- [React Router 7](https://reactrouter.com/) (SSR) -- [TipTap 3](https://tiptap.dev/) (editor) -- [Yjs](https://yjs.dev/) (CRDT for multiplayer) -- [Tailwind CSS 4](https://tailwindcss.com/) (styling) -- TypeScript, Vitest +# read any document back as raw markdown +curl https://vapor.fyi/.md +``` -## Getting started +- **Live markdown** — inline styles render as you type, formatting characters dimmed in place. +- **Suggest mode** — track changes as [CriticMarkup](https://criticmarkup.com/): additions, deletions, comments, highlights, with accept/reject. +- **Threaded comments** anchored to highlighted text. +- **Preview mode**, drag-and-drop `.md` import, dark/light/auto themes. -### Prerequisites +## Who you are -- Node.js 22+ (see `.nvmrc`) -- A Cloudflare account (free tier works) +Sign-in (Google) is optional everywhere and never a wall — it buys attribution, not access. Everyone at the table gets a name: -### Setup +| | Human | Agent | +|---|---|---| +| **Anonymous** | Curious Ladybug 🐞 | Agentic Butterfly 🦋 | +| **Signed in** | Ada Lovelace (+ avatar) | Ada's Agent | -```bash -git clone https://github.com/arfct/vapor.git -cd vapor -npm install -``` +Anonymous identities (an adjective, an animal, a colour) live in your own browser and persist across documents — you're the same Cowardly Lion everywhere. Sign in and your name and avatar replace the animal, your earlier anonymous comments in the doc are re-attributed to you, and your agent becomes a durable counterpart owned by your account. -### Development +## Agents + +vapor supplies the protocol and the presence; the intelligence is whatever [MCP](https://modelcontextprotocol.io) client you connect. Two doors: + +**Signed in** — the agent gets a stable identity across all documents and, if you grant it at consent, direct-write access. Adding it triggers a browser sign-in once, then it's remembered: ```bash -npm run dev +claude mcp add --transport http vapor https://vapor.fyi/mcp ``` -### Deploy +On claude.ai: Settings → Connectors → Add custom connector → `https://vapor.fyi/mcp`. -Set your Cloudflare account ID via environment variable: +**Anonymous** — zero setup, suggest and comment only: ```bash -export CLOUDFLARE_ACCOUNT_ID=your-account-id -npm run deploy +claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous ``` -### Optional: Analytics +Capabilities are chosen at consent: **suggest + comment** by default (tracked changes a human accepts or rejects — agents open PRs, they don't push to main), **full write** as an explicit opt-in. Each document's **Agents** panel shows who's enrolled and offers per-document revoke; revoking the OAuth grant severs the counterpart everywhere. -To enable [Fathom](https://usefathom.com/) analytics, set these environment variables (or add to `.dev.vars`): +### Tools -``` -VITE_FATHOM_SITE_ID=your-site-id -VITE_FATHOM_DOMAINS=your-domain.com -``` +`read_document` (markdown with stable per-block anchors, presence, threads) · `insert` · `replace` · `suggest` · `comment` · `reply` · `join` / `leave` · `await_events` (long-poll for mentions, replies, and change digests) · `create_document` -### Commands +Typing `@agent-name` in a document raises a `mention` event; an agent holding `await_events` open wakes on it. That's how you summon a specific collaborator mid-sentence. -```bash -npm run dev # Local development server -npm run build # Production build -npm run deploy # Build and deploy to Cloudflare Workers -npm run typecheck # TypeScript type checking -npm run lint # ESLint -npm run test # Vitest with coverage -npm run test:watch # Vitest in watch mode -``` +Edits from agents don't just appear — they type in at a human pace, cursor visible, with pauses at sentence ends. Pass `pace: "instant"` when nobody needs the theatre. + +## How it works -## Project structure +- [Cloudflare Workers](https://developers.cloudflare.com/workers/) + [Durable Objects](https://developers.cloudflare.com/durable-objects/) — one DO per document holds the [Yjs](https://yjs.dev/) CRDT, the agent roster, the typing-performance queue, and the event log. +- [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) — real-time WebSocket sync and the MCP server (`McpAgent`). +- [TipTap 3](https://tiptap.dev/) on [React Router 7](https://reactrouter.com/) (SSR), [Tailwind CSS 4](https://tailwindcss.com/), TypeScript, Vitest. +- Identity: Google sign-in via the GSI credential flow (no auth library, no client secret), HMAC session JWTs, and a hand-rolled OAuth 2.1 authorization server for MCP clients — PKCE, dynamic client registration, and CIMD. Ported from [subpixel](https://subpixel.app)'s auth stack. ``` -agents/ Durable Object agents (document state, MCP server) +agents/ Durable Objects: DocumentAgent, VaporMcp, Registry app/ components/ UI components - lib/ Editor logic, utilities, CriticMarkup, Yjs provider - routes/ File-based routing - shared/ Types and constants shared between client and server -workers/ Cloudflare Worker entry point -tests/ Test suite + lib/ Editor logic, CriticMarkup, Yjs provider, auth + routes/ File-based routes (docs live at /:id) + shared/ Types and constants shared client/server +workers/ Worker entry, pure route handlers, OAuth server +tests/ Unit + integration suites ``` -## AI agent collaborators +## Developing -Agents join a document as collaborators that look and behave like people: a name, a colour, a cursor, human-paced typing, suggestions, and comments. vapor supplies the protocol and the presence — the intelligence is whatever MCP client you connect. - -### Connecting - -Two doors. **`https://vapor.fyi/mcp`** is the main one — signing in gives the agent a stable identity (its own counterpart, owned by you) and, if you grant it at consent, `write` access: +Node 22+ and a Cloudflare account (free tier works). ```bash -claude mcp add --transport http vapor https://vapor.fyi/mcp +git clone https://github.com/arfct/vapor.git +cd vapor && npm install +npm run dev ``` -Adding it runs an OAuth flow: the client opens a browser sign-in the first time, then remembers it. On claude.ai, add a custom connector at Settings → Connectors → Add custom connector pointing at the same URL — sign-in happens in the consent popup. - -Prefer no account? **`https://vapor.fyi/mcp/anonymous`** connects with zero setup and can `suggest` and `comment`: - ```bash -claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous +npm run typecheck # cf-typegen + react-router typegen + tsc +npm run lint # ESLint +npm run test # Vitest with coverage +npm run deploy # build + wrangler deploy ``` -### Identity and capabilities - -Sign-in (Google) is optional everywhere — anonymous editing, anonymous MCP, and public-by-URL documents are unchanged. What identity buys is attribution and a durable counterpart agent: presence and comments show your name, and your agent's roster entries are owned by you across every document. - -At consent you choose the agent's capabilities: **suggest + comment** (the default — tracked changes a human accepts or rejects) or **full write** (direct edits). Anonymous agents are always suggest + comment. Revoke an agent from a document via its **Agents** panel, or revoke the whole grant to sever the counterpart everywhere. - -### Tools - -- `read_document` — markdown with per-block anchors, presence list, open threads -- `insert` — insert markdown before/after a block, or append to the doc -- `replace` — replace a block range -- `suggest` — CriticMarkup addition/deletion marks on matched text -- `comment` — open a thread anchored to a highlight -- `reply` — reply in a thread -- `join` / `leave` — enter/exit presence -- `await_events` — long-poll for mentions, thread replies, doc-changed digests -- `create_document` — create a new doc; the caller is enrolled as its first agent - -### Raw export - -`GET /:id.md` returns a document's markdown, public by URL, no token needed. - -### @mentions +Deploying needs `CLOUDFLARE_ACCOUNT_ID` in the environment. Sign-in needs two more pieces of config, both optional in dev (the app runs fine without them): `GOOGLE_CLIENT_ID` (a public Google OAuth client id — set as a wrangler var) and `SESSION_SECRET` (a Workers secret; locally, both go in `.dev.vars` — see `.dev.vars.example`). Fathom analytics is optional via `VITE_FATHOM_SITE_ID` / `VITE_FATHOM_DOMAINS`. -Typing `@agent-name` in a document raises a `mention` event. An agent holding `await_events` open wakes on it — this is how you summon a specific collaborator into a conversation. +Design and architecture docs live in [docs/](docs/), including the [agent collaborators spec](docs/plans/2026-08-30-agent-collaborators-design.md) and the [identity spec](docs/plans/2026-08-30-identity-design.md). -## Licence +## Fine print -[MIT](LICENSE) +[Privacy](https://vapor.fyi/privacy) · [Terms](https://vapor.fyi/terms) · [MIT](LICENSE) From 9b6ccfbb37bfd030ea7a5da948674a4764564534 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:40:37 -0700 Subject: [PATCH 58/83] Use neutral example identities in code, tests, and specs Co-Authored-By: Claude Fable 5 --- agents/mcp.ts | 2 +- app/shared/agent-protocol.ts | 2 +- docs/plans/2026-08-30-identity-design.md | 2 +- docs/plans/2026-08-30-identity-plan.md | 2 +- tests/integration/agents/registry.test.ts | 24 +++++++++++------------ tests/unit/agents/oauth.test.ts | 4 ++-- tests/unit/agents/worker-routes.test.ts | 16 +++++++-------- tests/unit/components/SignIn.test.tsx | 6 +++--- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/agents/mcp.ts b/agents/mcp.ts index bd972a10..298fc708 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -68,7 +68,7 @@ export class VaporMcp extends McpAgent, VaporMcpProps "slug" in ensured ? ensured.slug : slugifyAgentName(auth.email.split("@")[0] ?? "agent"); const { profile } = await registry.getProfile(auth.principal); const ownerName = profile?.displayName ?? auth.email.split("@")[0] ?? "Someone"; - // First name only: "Nicholas's Agent", not the full display name. + // First name only: "Ada's Agent", not the full display name. const firstName = ownerName.trim().split(/\s+/)[0] || "Someone"; this.agentLabel = `${firstName}'s Agent`; } diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index 34a43cfa..2e17fc51 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -32,7 +32,7 @@ export interface AgentIdentity { kind: "principal" | "anonymous"; id: string; // principal ("email:…") or anonymous session key name: string; // roster slug (agentSlug or slugified clientInfo) — used for @mentions - /** Human-facing attribution, e.g. "Nicholas Jitkoff's Agent". Falls back to name. */ + /** Human-facing attribution, e.g. "Ada Lovelace's Agent". Falls back to name. */ label?: string; owner: string | null; // principal for kind=principal, null for anonymous caps: AgentCapability[]; diff --git a/docs/plans/2026-08-30-identity-design.md b/docs/plans/2026-08-30-identity-design.md index e4c47767..9590c3fd 100644 --- a/docs/plans/2026-08-30-identity-design.md +++ b/docs/plans/2026-08-30-identity-design.md @@ -52,7 +52,7 @@ Migration note: this is a deliberate breaking change for existing `/mcp` clients ## Per-doc tokens retire -User-facing `vpr_` tokens are removed — they were the identity stopgap, and OAuth replaces them (Nicholas approved the break: no users to migrate). +User-facing `vpr_` tokens are removed — they were the identity stopgap, and OAuth replaces them (break approved by the maintainer: no users to migrate). - **Invite agent dialog** shrinks to what it should have been: connection instructions (the two doors) plus the roster with revoke. No minting, no one-time token screen, no capability switches — capabilities now live on the OAuth grant. - **Write capability** is granted at consent time, per user, instead of per doc. Per-doc revoke survives via the roster (severing that doc's enrollment); revoking the grant itself kills the counterpart everywhere. diff --git a/docs/plans/2026-08-30-identity-plan.md b/docs/plans/2026-08-30-identity-plan.md index 8cbb3867..bd0172b0 100644 --- a/docs/plans/2026-08-30-identity-plan.md +++ b/docs/plans/2026-08-30-identity-plan.md @@ -70,7 +70,7 @@ async ensureAgentSlug(principal: string): Promise<{ slug: string }> // slugify ``` `Profile = { uid, principal, displayName, avatar: string|null, agentSlug: string|null }`. Follow subpixel `registry.ts` key scheme (`p:`, `u:`, `a:` + `oc:`/`code:`/`rt:` for OAuth). Accessed via `getAgentByName(env.Registry, "global")`. -- [ ] Failing integration tests: profile upsert/get round-trip; slug uniquification (two principals, displayName "Nicholas J" → `nicholas-j`, `nicholas-j-2`); slug stability across calls; code single-use (second `takeCode` fails); refresh rotate invalidates old. +- [ ] Failing integration tests: profile upsert/get round-trip; slug uniquification (two principals, displayName "Ada L" → `ada-l`, `ada-l-2`); slug stability across calls; code single-use (second `takeCode` fails); refresh rotate invalidates old. - [ ] Implement → GREEN → gates → commit `Add global Registry durable object for profiles and OAuth state`. ### Task 3: Auth HTTP routes diff --git a/tests/integration/agents/registry.test.ts b/tests/integration/agents/registry.test.ts index 87a42add..40234b44 100644 --- a/tests/integration/agents/registry.test.ts +++ b/tests/integration/agents/registry.test.ts @@ -46,31 +46,31 @@ describe("Registry", () => { it("upserts and reads a profile, preserving uid and slug on update", async () => { const reg = makeRegistry(); - const { profile } = await reg.upsertProfile("email:nicholas@artifact.com", { - displayName: "Nicholas J", + const { profile } = await reg.upsertProfile("email:ada@example.com", { + displayName: "Ada L", }); expect(profile.uid).toBeTruthy(); expect(profile.agentSlug).toBeNull(); - const slug = await reg.ensureAgentSlug("email:nicholas@artifact.com"); - expect(slug).toEqual({ slug: "nicholas-j" }); + const slug = await reg.ensureAgentSlug("email:ada@example.com"); + expect(slug).toEqual({ slug: "ada-l" }); - const updated = await reg.upsertProfile("email:nicholas@artifact.com", { - displayName: "Nicholas", + const updated = await reg.upsertProfile("email:ada@example.com", { + displayName: "Ada", avatar: "https://example.com/a.png", }); expect(updated.profile.uid).toBe(profile.uid); - expect(updated.profile.agentSlug).toBe("nicholas-j"); + expect(updated.profile.agentSlug).toBe("ada-l"); expect(updated.profile.avatar).toBe("https://example.com/a.png"); }); it("uniquifies agent slugs globally and keeps them stable", async () => { const reg = makeRegistry(); - await reg.upsertProfile("email:a@x.com", { displayName: "Nicholas J" }); - await reg.upsertProfile("email:b@x.com", { displayName: "Nicholas J" }); - expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "nicholas-j" }); - expect(await reg.ensureAgentSlug("email:b@x.com")).toEqual({ slug: "nicholas-j-2" }); - expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "nicholas-j" }); + await reg.upsertProfile("email:a@x.com", { displayName: "Ada L" }); + await reg.upsertProfile("email:b@x.com", { displayName: "Ada L" }); + expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "ada-l" }); + expect(await reg.ensureAgentSlug("email:b@x.com")).toEqual({ slug: "ada-l-2" }); + expect(await reg.ensureAgentSlug("email:a@x.com")).toEqual({ slug: "ada-l" }); }); it("ensureAgentSlug without a profile errors", async () => { diff --git a/tests/unit/agents/oauth.test.ts b/tests/unit/agents/oauth.test.ts index 3cef9692..0f39f12d 100644 --- a/tests/unit/agents/oauth.test.ts +++ b/tests/unit/agents/oauth.test.ts @@ -148,7 +148,7 @@ describe("oauth authorization server", () => { const clientId = await registeredClient(registry); const { verifier, challenge } = await pkcePair(); const session = await mintSessionToken( - { principal: "email:nicholas@artifact.com", email: "nicholas@artifact.com" }, + { principal: "email:ada@example.com", email: "ada@example.com" }, SECRET, ); @@ -191,7 +191,7 @@ describe("oauth authorization server", () => { const tokens = (await tokenRes?.json()) as Record; expect(tokens.token_type).toBe("Bearer"); const claims = await verifySessionToken(tokens.access_token, SECRET); - expect(claims?.principal).toBe("email:nicholas@artifact.com"); + expect(claims?.principal).toBe("email:ada@example.com"); expect(claims?.caps).toEqual(["suggest", "comment", "write"]); // refresh rotation diff --git a/tests/unit/agents/worker-routes.test.ts b/tests/unit/agents/worker-routes.test.ts index c44b3e3e..240c3327 100644 --- a/tests/unit/agents/worker-routes.test.ts +++ b/tests/unit/agents/worker-routes.test.ts @@ -240,15 +240,15 @@ describe("handleAuth", () => { secret: "test-secret", googleClientId: "client-123", verifyGoogle: vi.fn(async () => ({ - email: "Nicholas@Artifact.com", - name: "Nicholas", + email: "Ada@Example.com", + name: "Ada", picture: "https://p/x.png", })), upsertProfile: vi.fn(async () => ({ - profile: { displayName: "Nicholas", agentSlug: null }, + profile: { displayName: "Ada", agentSlug: null }, })), getProfile: vi.fn(async () => ({ - profile: { displayName: "Nicholas", agentSlug: "nicholas" }, + profile: { displayName: "Ada", agentSlug: "ada" }, })), ...overrides, }; @@ -281,8 +281,8 @@ describe("handleAuth", () => { expect(cookie).toContain("SameSite=Lax"); expect(cookie).toContain("Secure"); expect(d.upsertProfile).toHaveBeenCalledWith( - "email:nicholas@artifact.com", - expect.objectContaining({ displayName: "Nicholas" }), + "email:ada@example.com", + expect.objectContaining({ displayName: "Ada" }), ); }); @@ -314,8 +314,8 @@ describe("handleAuth", () => { ); const body = (await res?.json()) as Record; expect(body.signedIn).toBe(true); - expect(body.principal).toBe("email:nicholas@artifact.com"); - expect(body.agentSlug).toBe("nicholas"); + expect(body.principal).toBe("email:ada@example.com"); + expect(body.agentSlug).toBe("ada"); }); it("logout clears the cookie", async () => { diff --git a/tests/unit/components/SignIn.test.tsx b/tests/unit/components/SignIn.test.tsx index 477ca082..440581bd 100644 --- a/tests/unit/components/SignIn.test.tsx +++ b/tests/unit/components/SignIn.test.tsx @@ -30,16 +30,16 @@ describe("SignIn", () => { it("shows the display name when signed in, in a sign-out button", async () => { vi.stubGlobal( "fetch", - mockFetch({ "/auth/me": { signedIn: true, displayName: "Nicholas" } }), + mockFetch({ "/auth/me": { signedIn: true, displayName: "Ada" } }), ); render(createElement(SignIn)); const btn = await screen.findByTitle("Sign out"); - expect(btn.textContent).toContain("Nicholas"); + expect(btn.textContent).toContain("Ada"); }); it("posts to /auth/logout on sign out", async () => { const fetchMock = mockFetch({ - "/auth/me": { signedIn: true, displayName: "Nicholas" }, + "/auth/me": { signedIn: true, displayName: "Ada" }, "POST /auth/logout": { ok: true }, }); vi.stubGlobal("fetch", fetchMock); From 46968ad4943f622990aa85af514daed896e3e3d5 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:55:16 -0700 Subject: [PATCH 59/83] Rewrite README in document voice Co-Authored-By: Claude Fable 5 --- README.md | 93 +++++++++++++++++-------------------------------------- 1 file changed, 28 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 5d63a807..4bf561cb 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,68 @@ # vapor -**[vapor.fyi](https://vapor.fyi)** — ephemeral, multiplayer markdown documents that people and AI agents edit together. +A markdown document should be a *place*, not a file—shared, live, briefly existing, and open to AI agents on the same terms as people. **[vapor.fyi](https://vapor.fyi)** is that place: a cross between GitHub Gist and Google Docs where an agent joins a document exactly the way a person does—a name, a color, a visible cursor, tracked-change suggestions, and typing you can watch. vapor is a fork of [mist](https://github.com/inanimate-tech/mist). -A cross between GitHub Gist and Google Docs: paste a URL at someone and you're co-writing, live cursors and all. Then invite an agent, and it joins the same document the same way — a name, a colour, a cursor, human-paced typing, tracked-change suggestions, and comments. +## Documents are public and temporary—by design -Every document is public to anyone holding its URL, saves itself continuously, and deletes itself about 99 hours after creation. vapor is a fork of [mist](https://github.com/inanimate-tech/mist). - -## Using it - -Go to [vapor.fyi](https://vapor.fyi) and start typing, or: +Anyone holding a document's URL can read and edit it. Every document deletes itself about 99 hours after creation. These two constraints are what make the rest simple: there are no accounts to require, no permissions to administer, and nothing to clean up. The tradeoff is explicit—vapor is for *working through* something together, not for storing it. Export before it expires. ```bash -# create a document from a file -curl https://vapor.fyi/new -T notes.md - -# read any document back as raw markdown -curl https://vapor.fyi/.md +curl https://vapor.fyi/new -T notes.md # create from a file +curl https://vapor.fyi/.md # read raw markdown back ``` -- **Live markdown** — inline styles render as you type, formatting characters dimmed in place. -- **Suggest mode** — track changes as [CriticMarkup](https://criticmarkup.com/): additions, deletions, comments, highlights, with accept/reject. -- **Threaded comments** anchored to highlighted text. -- **Preview mode**, drag-and-drop `.md` import, dark/light/auto themes. +Editing is live markdown—inline styles render as you type—with CriticMarkup track changes, threaded comments anchored to highlights, and a rendered preview. -## Who you are +## Everyone at the table has a name -Sign-in (Google) is optional everywhere and never a wall — it buys attribution, not access. Everyone at the table gets a name: +Identity in vapor is attribution, never access control. Sign-in (Google) is optional everywhere; what it changes is who your work is credited to: | | Human | Agent | |---|---|---| | **Anonymous** | Curious Ladybug 🐞 | Agentic Butterfly 🦋 | | **Signed in** | Ada Lovelace (+ avatar) | Ada's Agent | -Anonymous identities (an adjective, an animal, a colour) live in your own browser and persist across documents — you're the same Cowardly Lion everywhere. Sign in and your name and avatar replace the animal, your earlier anonymous comments in the doc are re-attributed to you, and your agent becomes a durable counterpart owned by your account. - -## Agents +An anonymous identity—adjective, animal, cursor color—lives in your browser and follows you across documents. Signing in replaces the animal with your name, re-attributes your earlier anonymous comments, and gives you a durable *counterpart agent* that acts as you across every document you point it at. -vapor supplies the protocol and the presence; the intelligence is whatever [MCP](https://modelcontextprotocol.io) client you connect. Two doors: +## Agents suggest; humans decide -**Signed in** — the agent gets a stable identity across all documents and, if you grant it at consent, direct-write access. Adding it triggers a browser sign-in once, then it's remembered: +vapor supplies the protocol and the presence—the intelligence is whatever [MCP](https://modelcontextprotocol.io) client you connect. Two doors: ```bash +# signed in: stable identity, and write access if you grant it at consent claude mcp add --transport http vapor https://vapor.fyi/mcp -``` - -On claude.ai: Settings → Connectors → Add custom connector → `https://vapor.fyi/mcp`. -**Anonymous** — zero setup, suggest and comment only: - -```bash +# anonymous: zero setup, suggest and comment only claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous ``` -Capabilities are chosen at consent: **suggest + comment** by default (tracked changes a human accepts or rejects — agents open PRs, they don't push to main), **full write** as an explicit opt-in. Each document's **Agents** panel shows who's enrolled and offers per-document revoke; revoking the OAuth grant severs the counterpart everywhere. +The default grant is **suggest + comment**—tracked changes a human accepts or rejects. **Full write** is an explicit opt-in at the consent screen. This mirrors how teams already work: agents open PRs; they don't push to main. Each document's Agents panel shows who's enrolled, with per-document revoke; revoking the OAuth grant severs the counterpart everywhere. -### Tools +Tools: `read_document` · `insert` · `replace` · `suggest` · `comment` · `reply` · `join`/`leave` · `await_events` · `create_document`. Typing `@agent-name` raises a mention event that wakes any agent long-polling `await_events`—that is how you summon a collaborator mid-sentence. Agent edits type in at human pace, cursor visible; pass `pace: "instant"` when nobody needs the theatre. -`read_document` (markdown with stable per-block anchors, presence, threads) · `insert` · `replace` · `suggest` · `comment` · `reply` · `join` / `leave` · `await_events` (long-poll for mentions, replies, and change digests) · `create_document` +## One Durable Object per document -Typing `@agent-name` in a document raises a `mention` event; an agent holding `await_events` open wakes on it. That's how you summon a specific collaborator mid-sentence. - -Edits from agents don't just appear — they type in at a human pace, cursor visible, with pauses at sentence ends. Pass `pace: "instant"` when nobody needs the theatre. - -## How it works - -- [Cloudflare Workers](https://developers.cloudflare.com/workers/) + [Durable Objects](https://developers.cloudflare.com/durable-objects/) — one DO per document holds the [Yjs](https://yjs.dev/) CRDT, the agent roster, the typing-performance queue, and the event log. -- [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) — real-time WebSocket sync and the MCP server (`McpAgent`). -- [TipTap 3](https://tiptap.dev/) on [React Router 7](https://reactrouter.com/) (SSR), [Tailwind CSS 4](https://tailwindcss.com/), TypeScript, Vitest. -- Identity: Google sign-in via the GSI credential flow (no auth library, no client secret), HMAC session JWTs, and a hand-rolled OAuth 2.1 authorization server for MCP clients — PKCE, dynamic client registration, and CIMD. Ported from [subpixel](https://subpixel.app)'s auth stack. +Each document is a Cloudflare Durable Object holding the [Yjs](https://yjs.dev/) CRDT, the agent roster, the typing-performance queue, and the event log—sync, presence, and agent state live where the document lives. Around it: the [Agents SDK](https://developers.cloudflare.com/agents/) for WebSockets and MCP, [TipTap](https://tiptap.dev/) on [React Router 7](https://reactrouter.com/), and a dependency-free identity stack (Google sign-in via GSI, HMAC session JWTs, a hand-rolled OAuth 2.1 server with PKCE, dynamic registration, and CIMD) ported from [subpixel](https://subpixel.app). ``` -agents/ Durable Objects: DocumentAgent, VaporMcp, Registry -app/ - components/ UI components - lib/ Editor logic, CriticMarkup, Yjs provider, auth - routes/ File-based routes (docs live at /:id) - shared/ Types and constants shared client/server -workers/ Worker entry, pure route handlers, OAuth server -tests/ Unit + integration suites +agents/ Durable Objects: DocumentAgent, VaporMcp, Registry +app/ React Router app: components, editor logic, routes, shared types +workers/ Worker entry, route handlers, OAuth server +tests/ Unit + integration suites ``` -## Developing +## Working on it -Node 22+ and a Cloudflare account (free tier works). +Node 22+ and a free-tier Cloudflare account suffice. ```bash -git clone https://github.com/arfct/vapor.git -cd vapor && npm install -npm run dev +git clone https://github.com/arfct/vapor.git && cd vapor && npm install +npm run dev # local server +npm run test # vitest with coverage; also: typecheck, lint +npm run deploy # build + wrangler deploy (needs CLOUDFLARE_ACCOUNT_ID) ``` -```bash -npm run typecheck # cf-typegen + react-router typegen + tsc -npm run lint # ESLint -npm run test # Vitest with coverage -npm run deploy # build + wrangler deploy -``` - -Deploying needs `CLOUDFLARE_ACCOUNT_ID` in the environment. Sign-in needs two more pieces of config, both optional in dev (the app runs fine without them): `GOOGLE_CLIENT_ID` (a public Google OAuth client id — set as a wrangler var) and `SESSION_SECRET` (a Workers secret; locally, both go in `.dev.vars` — see `.dev.vars.example`). Fathom analytics is optional via `VITE_FATHOM_SITE_ID` / `VITE_FATHOM_DOMAINS`. - -Design and architecture docs live in [docs/](docs/), including the [agent collaborators spec](docs/plans/2026-08-30-agent-collaborators-design.md) and the [identity spec](docs/plans/2026-08-30-identity-design.md). +Sign-in needs `GOOGLE_CLIENT_ID` (public, a wrangler var) and `SESSION_SECRET` (a Workers secret); both are optional in development and documented in `.dev.vars.example`. Design docs live in [docs/](docs/)—start with the [agent collaborators spec](docs/plans/2026-08-30-agent-collaborators-design.md) and the [identity spec](docs/plans/2026-08-30-identity-design.md). ## Fine print From 2e210c7f326d3eeb356b973113f1ed335bcd5b37 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:27:59 -0700 Subject: [PATCH 60/83] Cut README back to plain facts Co-Authored-By: Claude Fable 5 --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4bf561cb..859bb87f 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,67 @@ # vapor -A markdown document should be a *place*, not a file—shared, live, briefly existing, and open to AI agents on the same terms as people. **[vapor.fyi](https://vapor.fyi)** is that place: a cross between GitHub Gist and Google Docs where an agent joins a document exactly the way a person does—a name, a color, a visible cursor, tracked-change suggestions, and typing you can watch. vapor is a fork of [mist](https://github.com/inanimate-tech/mist). +You paste a draft into chat and now there are two copies, both going stale. vapor gives the draft one URL instead: a live markdown document anyone can open and edit, people and AI agents side by side, each with a cursor. It deletes itself after 99 hours. -## Documents are public and temporary—by design +Running at [vapor.fyi](https://vapor.fyi). A fork of [mist](https://github.com/inanimate-tech/mist). -Anyone holding a document's URL can read and edit it. Every document deletes itself about 99 hours after creation. These two constraints are what make the rest simple: there are no accounts to require, no permissions to administer, and nothing to clean up. The tradeoff is explicit—vapor is for *working through* something together, not for storing it. Export before it expires. +## Documents + +Anyone with the URL can read and edit. Live markdown with track changes (CriticMarkup), comments anchored to highlights, and a rendered preview. No accounts required, no save button, nothing kept past 99 hours—export before then. ```bash curl https://vapor.fyi/new -T notes.md # create from a file -curl https://vapor.fyi/.md # read raw markdown back +curl https://vapor.fyi/.md # raw markdown back ``` -Editing is live markdown—inline styles render as you type—with CriticMarkup track changes, threaded comments anchored to highlights, and a rendered preview. - -## Everyone at the table has a name +## People and agents -Identity in vapor is attribution, never access control. Sign-in (Google) is optional everywhere; what it changes is who your work is credited to: +Sign-in (Google) is optional and only changes attribution: | | Human | Agent | |---|---|---| -| **Anonymous** | Curious Ladybug 🐞 | Agentic Butterfly 🦋 | -| **Signed in** | Ada Lovelace (+ avatar) | Ada's Agent | +| Anonymous | Curious Ladybug 🐞 | Agentic Butterfly 🦋 | +| Signed in | Ada Lovelace | Ada's Agent | -An anonymous identity—adjective, animal, cursor color—lives in your browser and follows you across documents. Signing in replaces the animal with your name, re-attributes your earlier anonymous comments, and gives you a durable *counterpart agent* that acts as you across every document you point it at. +Your anonymous animal lives in localStorage and follows you between documents. Sign in and your name takes over, earlier comments included. -## Agents suggest; humans decide +## Connecting an agent -vapor supplies the protocol and the presence—the intelligence is whatever [MCP](https://modelcontextprotocol.io) client you connect. Two doors: +vapor is an [MCP](https://modelcontextprotocol.io) server. Two ways in: ```bash -# signed in: stable identity, and write access if you grant it at consent +# signed in: stable identity, write access if you grant it claude mcp add --transport http vapor https://vapor.fyi/mcp -# anonymous: zero setup, suggest and comment only +# anonymous: no setup, suggest and comment only claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous ``` -The default grant is **suggest + comment**—tracked changes a human accepts or rejects. **Full write** is an explicit opt-in at the consent screen. This mirrors how teams already work: agents open PRs; they don't push to main. Each document's Agents panel shows who's enrolled, with per-document revoke; revoking the OAuth grant severs the counterpart everywhere. +Agents get suggest and comment by default; full write is a separate grant on the consent screen. Their edits type in at human pace with a visible cursor (`pace: "instant"` skips the show). Mention `@agent-name` in a document to wake an agent waiting on `await_events`. -Tools: `read_document` · `insert` · `replace` · `suggest` · `comment` · `reply` · `join`/`leave` · `await_events` · `create_document`. Typing `@agent-name` raises a mention event that wakes any agent long-polling `await_events`—that is how you summon a collaborator mid-sentence. Agent edits type in at human pace, cursor visible; pass `pace: "instant"` when nobody needs the theatre. +Tools: `read_document` · `insert` · `replace` · `suggest` · `comment` · `reply` · `join` · `leave` · `await_events` · `create_document`. Each document's Agents panel lists who's enrolled, with revoke. -## One Durable Object per document +## How it's built -Each document is a Cloudflare Durable Object holding the [Yjs](https://yjs.dev/) CRDT, the agent roster, the typing-performance queue, and the event log—sync, presence, and agent state live where the document lives. Around it: the [Agents SDK](https://developers.cloudflare.com/agents/) for WebSockets and MCP, [TipTap](https://tiptap.dev/) on [React Router 7](https://reactrouter.com/), and a dependency-free identity stack (Google sign-in via GSI, HMAC session JWTs, a hand-rolled OAuth 2.1 server with PKCE, dynamic registration, and CIMD) ported from [subpixel](https://subpixel.app). +Each document is one Cloudflare Durable Object holding the [Yjs](https://yjs.dev/) doc, agent roster, and event log. [TipTap](https://tiptap.dev/) and [React Router 7](https://reactrouter.com/) on the front, the [Agents SDK](https://developers.cloudflare.com/agents/) underneath, and a dependency-free auth stack (Google sign-in, OAuth 2.1 with PKCE and CIMD) ported from [subpixel](https://subpixel.app). ``` agents/ Durable Objects: DocumentAgent, VaporMcp, Registry -app/ React Router app: components, editor logic, routes, shared types -workers/ Worker entry, route handlers, OAuth server -tests/ Unit + integration suites +app/ React Router app +workers/ Worker entry, routes, OAuth server +tests/ Unit + integration ``` -## Working on it +## Developing -Node 22+ and a free-tier Cloudflare account suffice. +Node 22+. ```bash -git clone https://github.com/arfct/vapor.git && cd vapor && npm install -npm run dev # local server -npm run test # vitest with coverage; also: typecheck, lint -npm run deploy # build + wrangler deploy (needs CLOUDFLARE_ACCOUNT_ID) +npm install +npm run dev # local server +npm run test # also: typecheck, lint +npm run deploy # needs CLOUDFLARE_ACCOUNT_ID ``` -Sign-in needs `GOOGLE_CLIENT_ID` (public, a wrangler var) and `SESSION_SECRET` (a Workers secret); both are optional in development and documented in `.dev.vars.example`. Design docs live in [docs/](docs/)—start with the [agent collaborators spec](docs/plans/2026-08-30-agent-collaborators-design.md) and the [identity spec](docs/plans/2026-08-30-identity-design.md). - -## Fine print +Sign-in needs `GOOGLE_CLIENT_ID` (a wrangler var) and `SESSION_SECRET` (a Workers secret); both optional in development. See `.dev.vars.example`. Design docs live in [docs/plans/](docs/plans/). [Privacy](https://vapor.fyi/privacy) · [Terms](https://vapor.fyi/terms) · [MIT](LICENSE) From 392b0ffae072e84e90893f2c22c89de097c86833 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:56:59 -0700 Subject: [PATCH 61/83] Redesign toolbar menus, comment cards, and mark underlines - Comment/highlight marks use a solid 3px canary border-bottom instead of text-decoration, which fell back to a black currentColor bar when the shorthand's variable failed to resolve - Edit/Suggest becomes a header menu with Accept all / Reject all - Top-right header menu consolidates connection status, Agents, the account row (Google sign-in or name + sign-out), and a one-row light/dark/auto theme switcher - Comment threads restyled: avatar with stacked name/date, resolve check and overflow menu in the header, always-visible rounded reply input - Material Symbols Outlined (subset) for icon buttons - New vapor mark stored at public/logo.png; favicon.ico, favicon-32, apple-touch-icon, and og:image generated from it Co-Authored-By: Claude Fable 5 --- app/app.css | 38 +++-- app/components/AgentsPanel.tsx | 26 +-- app/components/HeaderMenu.tsx | 156 ++++++++++++++++++ app/components/Icon.tsx | 11 ++ app/components/MobilePanel.tsx | 2 - app/components/ModeMenu.tsx | 84 ++++++++++ app/components/ModeToggle.tsx | 23 --- app/components/SignIn.tsx | 113 ------------- app/components/SuggestionActions.tsx | 59 ++----- app/components/ThreadPanel.tsx | 171 +++++++++++--------- app/lib/DocumentContext.tsx | 2 + app/root.tsx | 9 ++ app/routes/doc.$id.tsx | 22 +-- app/routes/home.tsx | 1 + public/apple-touch-icon.png | Bin 0 -> 16390 bytes public/favicon-32.png | Bin 0 -> 3161 bytes public/favicon.ico | Bin 15086 -> 8511 bytes public/logo-512.png | Bin 0 -> 51405 bytes public/logo.png | Bin 0 -> 47028 bytes tests/helpers/document-context.tsx | 1 + tests/unit/components/SignIn.test.tsx | 52 ------ tests/unit/components/header-menu.test.tsx | 63 ++++++++ tests/unit/components/mobile-panel.test.tsx | 15 +- tests/unit/components/mode-menu.test.tsx | 21 +++ tests/unit/components/mode-toggle.test.tsx | 30 ---- tests/unit/components/remaining.test.tsx | 2 - tests/unit/components/thread-panel.test.tsx | 78 ++++----- 27 files changed, 537 insertions(+), 442 deletions(-) create mode 100644 app/components/HeaderMenu.tsx create mode 100644 app/components/Icon.tsx create mode 100644 app/components/ModeMenu.tsx delete mode 100644 app/components/ModeToggle.tsx delete mode 100644 app/components/SignIn.tsx create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-32.png create mode 100644 public/logo-512.png create mode 100644 public/logo.png delete mode 100644 tests/unit/components/SignIn.test.tsx create mode 100644 tests/unit/components/header-menu.test.tsx create mode 100644 tests/unit/components/mode-menu.test.tsx delete mode 100644 tests/unit/components/mode-toggle.test.tsx diff --git a/app/app.css b/app/app.css index a030be66..be4335c5 100644 --- a/app/app.css +++ b/app/app.css @@ -69,6 +69,23 @@ body { pointer-events: none; } +/* Material Symbols Outlined icon glyphs (ligature-based). */ +.material-symbols-outlined { + font-family: "Material Symbols Outlined"; + font-weight: normal; + font-style: normal; + font-size: 1.3em; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + vertical-align: -0.28em; + font-variation-settings: "opsz" 20; +} + /* Monochrome animal glyphs (Noto Emoji) — tinted via `color`. */ .anon-animal { font-family: "Noto Emoji", var(--font-sans); @@ -213,13 +230,12 @@ body { text-decoration: line-through; } +/* Comment/highlight underline: border-bottom rather than text-decoration — + decoration shorthands fall back to currentColor when the variable fails + to resolve, which rendered as a black bar in some contexts. */ .cm-comment, .cm-highlight { - text-decoration: underline solid var(--color-canary); - text-decoration-color: var(--color-canary) !important; - text-decoration-thickness: 4.5px; - text-underline-offset: 2px; - text-decoration-skip-ink: none; + border-bottom: 3px solid var(--color-canary, #ffe014); } /* Active: underline + background */ @@ -232,7 +248,7 @@ body { display: inline-block; position: relative; width: 8px; - height: 4.5px; + height: 3px; background: var(--color-canary); vertical-align: -2px; margin: 0 1px; @@ -259,7 +275,7 @@ body { .cm-comment-active .cm-point-marker { height: 1.3em; background-color: color-mix(in srgb, var(--color-canary) 30%, transparent); - border-bottom: 4.5px solid var(--color-canary); + border-bottom: 3px solid var(--color-canary); } :has(> .cm-comment-active) > .cm-point-marker::before, @@ -274,7 +290,7 @@ body { .clean-view .cm-comment { font-size: 0; - text-decoration: none; + border-bottom: none; } /* Keep point markers visible inside hidden comment spans */ @@ -379,11 +395,7 @@ body { .preview .cm-comment, .preview .cm-highlight { - text-decoration: underline solid var(--color-canary); - text-decoration-color: var(--color-canary) !important; - text-decoration-thickness: 4.5px; - text-underline-offset: 2px; - text-decoration-skip-ink: none; + border-bottom: 3px solid var(--color-canary, #ffe014); } /* Dark theme — explicit dark mode */ diff --git a/app/components/AgentsPanel.tsx b/app/components/AgentsPanel.tsx index 55dda541..88affefb 100644 --- a/app/components/AgentsPanel.tsx +++ b/app/components/AgentsPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useState } from "react"; import { useParams } from "react-router"; import type { AgentRosterEntry } from "~/shared/agent-protocol"; @@ -46,13 +46,11 @@ function SnippetRow({ label, text }: { label: string; text: string }) { * agents authenticate via OAuth (or the anonymous door) and enroll on first * touch. */ -export default function AgentsPanel() { +export default function AgentsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { const params = useParams(); const docId = params.id ?? ""; - const [open, setOpen] = useState(false); const [roster, setRoster] = useState([]); const titleId = useId(); - const triggerRef = useRef(null); const origin = typeof window !== "undefined" ? window.location.origin : "https://vapor.fyi"; const loadRoster = useCallback(() => { @@ -66,19 +64,14 @@ export default function AgentsPanel() { if (open) loadRoster(); }, [open, loadRoster]); - const handleClose = useCallback(() => { - setOpen(false); - triggerRef.current?.focus(); - }, []); - useEffect(() => { if (!open) return; function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") handleClose(); + if (e.key === "Escape") onClose(); } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [open, handleClose]); + }, [open, onClose]); async function handleRevoke(name: string) { await fetch(`/${docId}/agents`, { @@ -90,7 +83,7 @@ export default function AgentsPanel() { } function handleOverlayClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) handleClose(); + if (e.target === e.currentTarget) onClose(); } const claudeCodeCommand = `claude mcp add --transport http vapor ${origin}/mcp`; @@ -98,13 +91,6 @@ export default function AgentsPanel() { return ( <> - {open && (
+ {open && ( + <> +
setOpen(false)} /> +
+
+ +
+ + {session?.signedIn ? ( +
+ {session.avatar && } + {session.displayName} + +
+ ) : ( +
+
+
+ )} +
+ Theme +
+ {themeOptions.map((t) => ( + + ))} +
+
+
+ + )} + + ); +} diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx new file mode 100644 index 00000000..e5887814 --- /dev/null +++ b/app/components/Icon.tsx @@ -0,0 +1,11 @@ +/** + * Material Symbols Outlined glyph. `name` must appear in the icon_names + * subset in root.tsx or the ligature renders as raw text. + */ +export default function Icon({ name, className }: { name: string; className?: string }) { + return ( + + ); +} diff --git a/app/components/MobilePanel.tsx b/app/components/MobilePanel.tsx index 9c562f94..694d0162 100644 --- a/app/components/MobilePanel.tsx +++ b/app/components/MobilePanel.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef } from "react"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; import SuggestionActions from "~/components/SuggestionActions"; -import ModeToggle from "~/components/ModeToggle"; import PreviewToggle from "~/components/PreviewToggle"; import OnboardingBanner from "~/components/OnboardingBanner"; import { useDocument } from "~/lib/DocumentContext"; @@ -60,7 +59,6 @@ export default function MobilePanel({ className }: { className?: string }) { {activeTab === "editing" && ( <> - )} diff --git a/app/components/ModeMenu.tsx b/app/components/ModeMenu.tsx new file mode 100644 index 00000000..ce277d02 --- /dev/null +++ b/app/components/ModeMenu.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { useDocument } from "~/lib/DocumentContext"; +import { hasSuggestionMarkup, processAllRanges } from "~/lib/suggestion-actions"; +import Icon from "~/components/Icon"; + +function ChevronDown() { + return ( + + + + ); +} + +/** + * Header menu for the editing mode. Edit and Suggest switch modes; + * Accept all / Reject all apply to every pending suggestion. + */ +export default function ModeMenu() { + const { editorInstance: editor, mode, setMode } = useDocument(); + const [hasSuggestions, setHasSuggestions] = useState(false); + + useEffect(() => { + if (!editor) return; + const update = () => setHasSuggestions(hasSuggestionMarkup(editor)); + update(); + editor.on("update", update); + return () => { + editor.off("update", update); + }; + }, [editor]); + + const itemClass = + "flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-border data-[disabled]:cursor-default data-[disabled]:text-muted/40"; + + return ( + + + + + + + setMode("edit")} className={itemClass}> + + Edit + {mode === "edit" && {"✓"}} + + setMode("suggest")} className={itemClass}> + + Suggest + {mode === "suggest" && {"✓"}} + + + editor && processAllRanges(editor, true)} + className={itemClass} + > + + Accept all + + editor && processAllRanges(editor, false)} + className={itemClass} + > + + Reject all + + + + + ); +} diff --git a/app/components/ModeToggle.tsx b/app/components/ModeToggle.tsx deleted file mode 100644 index 5010f804..00000000 --- a/app/components/ModeToggle.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as Switch from "@radix-ui/react-switch"; -import { useDocument } from "~/lib/DocumentContext"; - -export default function ModeToggle() { - const { mode, toggleMode } = useDocument(); - const isSuggest = mode === "suggest"; - - return ( -
- - {isSuggest ? "Suggest changes" : "Edit mode"} - - - - -
- ); -} diff --git a/app/components/SignIn.tsx b/app/components/SignIn.tsx deleted file mode 100644 index 8ba4e8b5..00000000 --- a/app/components/SignIn.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useSession, notifyAuthChanged } from "~/lib/useSession"; - -declare global { - interface Window { - google?: { - accounts: { - id: { - initialize: (opts: { client_id: string; callback: (r: { credential: string }) => void }) => void; - renderButton: (el: HTMLElement, opts: Record) => void; - }; - }; - }; - } -} - -/** - * Header sign-in affordance. Signed out: a "Sign in" button that opens a - * popover and loads Google Identity Services on demand. Signed in: the - * avatar + display name plus sign-out. Optional everywhere. - * - * The button is a flush toolbar item (a direct sibling of the other header - * controls); the popover is fixed-position so it never widens the - * horizontally-scrolling header. - */ -export default function SignIn() { - const session = useSession(); - const [open, setOpen] = useState(false); - const buttonHost = useRef(null); - - // Load GSI and render the Google button only when the popover opens. - useEffect(() => { - if (!open || !buttonHost.current) return; - let cancelled = false; - - async function mount() { - const config = (await fetch("/auth/config").then((r) => r.json())) as { - googleClientId?: string; - }; - if (cancelled || !config.googleClientId) return; - - const render = () => { - if (cancelled || !window.google || !buttonHost.current) return; - window.google.accounts.id.initialize({ - client_id: config.googleClientId as string, - callback: async (r) => { - const res = await fetch("/auth/google", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ credential: r.credential }), - }); - if (res.ok) { - setOpen(false); - notifyAuthChanged(); - } - }, - }); - window.google.accounts.id.renderButton(buttonHost.current, { theme: "outline" }); - }; - - if (window.google) { - render(); - } else { - const s = document.createElement("script"); - s.src = "https://accounts.google.com/gsi/client"; - s.async = true; - s.onload = render; - document.head.appendChild(s); - } - } - mount(); - return () => { - cancelled = true; - }; - }, [open]); - - async function signOut() { - await fetch("/auth/logout", { method: "POST" }); - notifyAuthChanged(); - } - - if (session?.signedIn) { - return ( - - ); - } - - return ( - <> - - {open && ( - <> -
setOpen(false)} /> -
-
-
- - )} - - ); -} diff --git a/app/components/SuggestionActions.tsx b/app/components/SuggestionActions.tsx index 7950ea74..3f37fd1e 100644 --- a/app/components/SuggestionActions.tsx +++ b/app/components/SuggestionActions.tsx @@ -3,7 +3,6 @@ import { useDocument } from "~/lib/DocumentContext"; import { hasSuggestionMarkup, isCursorInSuggestion, - processAllRanges, processRangeAtCursor, } from "~/lib/suggestion-actions"; @@ -29,16 +28,6 @@ export default function SuggestionActions() { }; }, [editor]); - const handleAcceptAll = useCallback(() => { - if (!editor) return; - processAllRanges(editor, true); - }, [editor]); - - const handleRejectAll = useCallback(() => { - if (!editor) return; - processAllRanges(editor, false); - }, [editor]); - const handleAcceptAtCursor = useCallback(() => { if (!editor) return; processRangeAtCursor(editor, true); @@ -60,39 +49,21 @@ export default function SuggestionActions() { "flex-1 cursor-default border border-border px-2 py-1.5 text-sm uppercase tracking-wider text-muted/40 transition-colors"; return ( -
-
- - -
-
- - -
+
+ +
); } diff --git a/app/components/ThreadPanel.tsx b/app/components/ThreadPanel.tsx index 9b27e269..f2cae702 100644 --- a/app/components/ThreadPanel.tsx +++ b/app/components/ThreadPanel.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; import type { ThreadData } from "~/shared/types"; +import Icon from "~/components/Icon"; function timeAgo(ts: number): string { const seconds = Math.floor((Date.now() - ts) / 1000); @@ -13,7 +14,39 @@ function timeAgo(ts: number): string { } function truncate(text: string, max: number): string { - return text.length > max ? text.slice(0, max) + "\u2026" : text; + return text.length > max ? text.slice(0, max) + "…" : text; +} + +function AuthorHeader({ + author, + timestamp, + children, +}: { + author: ThreadData["author"]; + timestamp: number; + children?: React.ReactNode; +}) { + return ( +
+ {author.avatar ? ( + + ) : author.animal ? ( + + {author.animal} + + ) : ( + + )} +
+ {author.name} + {timeAgo(timestamp)} +
+ {children} +
+ ); } interface ThreadPanelProps { @@ -34,20 +67,22 @@ export default function ThreadPanel({ onDelete, }: ThreadPanelProps) { const [replyText, setReplyText] = useState(""); - const [showReplyInput, setShowReplyInput] = useState(false); - const inputRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); useEffect(() => { - if (showReplyInput && inputRef.current) { - inputRef.current.focus(); + if (!menuOpen) return; + function onPointerDown(e: PointerEvent) { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); } - }, [showReplyInput]); + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [menuOpen]); const handleReplySubmit = useCallback(() => { if (!replyText.trim()) return; onReply(thread.id, replyText.trim()); setReplyText(""); - setShowReplyInput(false); }, [thread.id, replyText, onReply]); const handleReplyKeyDown = useCallback( @@ -57,7 +92,6 @@ export default function ThreadPanel({ handleReplySubmit(); } else if (e.key === "Escape") { setReplyText(""); - setShowReplyInput(false); } }, [handleReplySubmit], @@ -68,92 +102,79 @@ export default function ThreadPanel({ className={`cursor-pointer p-3 ${active ? "bg-canary/15" : ""}`} onClick={() => onSelect(active ? null : thread.id)} > - {/* Author + timestamp */} -
- {thread.author.avatar ? ( - - ) : ( - thread.author.animal && ( - - {thread.author.animal} - - ) - )} - {thread.author.name} - {timeAgo(thread.createdAt)} -
+ {/* Author + timestamp + actions */} + +
e.stopPropagation()} + > + +
+ + {menuOpen && ( +
+ +
+ )} +
+
+
{/* Highlight context */} {thread.highlightText && ( -
+
{truncate(thread.highlightText, 80)}
)} {/* Comment text */} -

{thread.commentText}

+

{thread.commentText}

{/* Replies */} {thread.replies.length > 0 && ( -
+
{thread.replies.map((reply) => (
-
- {reply.author.avatar ? ( - - ) : ( - reply.author.animal && ( - - {reply.author.animal} - - ) - )} - {reply.author.name} - - {timeAgo(reply.createdAt)} - -
-

{reply.text}

+ +

{reply.text}

))}
)} {/* Reply input */} - {showReplyInput && ( -
e.stopPropagation()}> - setReplyText(e.target.value)} - onKeyDown={handleReplyKeyDown} - placeholder="Reply..." - className="w-full border border-border bg-paper px-2 py-1 outline-none focus:border-coral" - /> -
- )} - - {/* Actions */} -
e.stopPropagation()}> - - - +
e.stopPropagation()}> + setReplyText(e.target.value)} + onKeyDown={handleReplyKeyDown} + placeholder="Reply..." + className="w-full rounded-full border border-border bg-paper px-3 py-1.5 outline-none focus:border-coral" + />
); diff --git a/app/lib/DocumentContext.tsx b/app/lib/DocumentContext.tsx index e8be9491..e01db2fa 100644 --- a/app/lib/DocumentContext.tsx +++ b/app/lib/DocumentContext.tsx @@ -16,6 +16,7 @@ export interface DocumentContextValue { // Mode mode: DocMode; + setMode: (mode: DocMode) => void; toggleMode: () => void; // Preview @@ -222,6 +223,7 @@ export function DocumentProvider({ editorInstance, markdown, mode: yjs.mode, + setMode: yjs.setMode, toggleMode, showPreview, togglePreview, diff --git a/app/root.tsx b/app/root.tsx index e6417d4c..0426c1e7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -13,6 +13,9 @@ import Fathom from "~/components/Fathom"; import "./app.css"; export const links: Route.LinksFunction = () => [ + { rel: "icon", href: "/favicon.ico", sizes: "48x48" }, + { rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" }, + { rel: "apple-touch-icon", href: "/apple-touch-icon.png" }, { rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", @@ -29,6 +32,12 @@ export const links: Route.LinksFunction = () => [ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@400;600&display=swap", }, + { + // Subset to the icon names actually used — keep this list sorted and in + // sync with usages or new glyphs render as raw text. + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=account_circle,brightness_auto,check,dark_mode,delete,done_all,edit,light_mode,logout,more_vert,rate_review,remove_done,smart_toy,undo&display=block", + }, ]; const themeScript = `(function(){var t=localStorage.getItem('vapor-theme')||'auto';document.documentElement.setAttribute('data-theme',t)})()`; diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index efd7c452..6dced2cd 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { data, Link } from "react-router"; import type { Route } from "./+types/doc.$id"; import { getAgentByName } from "agents"; @@ -9,16 +10,14 @@ import { DocumentProvider, useDocument } from "~/lib/DocumentContext"; import Editor from "~/components/Editor"; import Preview from "~/components/Preview"; import PreviewToggle from "~/components/PreviewToggle"; -import ConnectionStatus from "~/components/ConnectionStatus"; import ShareButton from "~/components/ShareButton"; import AgentsPanel from "~/components/AgentsPanel"; -import ModeToggle from "~/components/ModeToggle"; +import ModeMenu from "~/components/ModeMenu"; +import HeaderMenu from "~/components/HeaderMenu"; import CleanViewToggle from "~/components/CleanViewToggle"; import SuggestionActions from "~/components/SuggestionActions"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; -import ThemeSelector from "~/components/ThemeSelector"; -import SignIn from "~/components/SignIn"; import MobilePanel from "~/components/MobilePanel"; import OnboardingBanner from "~/components/OnboardingBanner"; @@ -88,6 +87,7 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul handleDeleteAtCursor, mode, } = useDocument(); + const [agentsOpen, setAgentsOpen] = useState(false); return (
@@ -106,22 +106,17 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul )}
-
- -
- +
- +
- -
-
- + setAgentsOpen(true)} />
+ setAgentsOpen(false)} />
- {mode === "suggest" && }
diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 4e221b47..104a5671 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -15,6 +15,7 @@ export function meta(_args: Route.MetaArgs) { return [ { title: "vapor" }, { name: "description", content: "Collaborative markdown editor" }, + { property: "og:image", content: "https://vapor.fyi/logo-512.png" }, ]; } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b3114ee2988793f6e1ecad487558c8c9331f9126 GIT binary patch literal 16390 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

+_b$w?WqdXtN;08UgSHZiPFCvY!`({ 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 Xu8jVq!pZ;V%C@AaoJft3LD2sPnNmK! literal 0 HcmV?d00001 diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c28572a3f29d35a837fbccd984dde27aec2bd32b GIT binary patch literal 3161 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 Sews<5S^e*?694zD@c#f(a8(}w literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j diff --git a/public/logo-512.png b/public/logo-512.png new file mode 100644 index 0000000000000000000000000000000000000000..40c47d1f556cfd99093983a2f4adb612adb56811 GIT binary patch literal 51405 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 Hu0mjf{Nsy4 literal 0 HcmV?d00001 diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cfba6f6254c21409c515841567a3a7850a0abe71 GIT binary patch literal 47028 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 { - return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.toString(); - const key = `${init?.method ?? "GET"} ${new URL(url, "https://vapor.fyi").pathname}`; - const body = routes[key] ?? routes[new URL(url, "https://vapor.fyi").pathname] ?? {}; - return { ok: true, json: async () => body } as Response; - }); -} - -describe("SignIn", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch({ "/auth/me": { signedIn: false } })); - }); - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("renders a Sign in button when signed out", async () => { - render(createElement(SignIn)); - expect(await screen.findByText("Sign in")).toBeTruthy(); - }); - - it("shows the display name when signed in, in a sign-out button", async () => { - vi.stubGlobal( - "fetch", - mockFetch({ "/auth/me": { signedIn: true, displayName: "Ada" } }), - ); - render(createElement(SignIn)); - const btn = await screen.findByTitle("Sign out"); - expect(btn.textContent).toContain("Ada"); - }); - - it("posts to /auth/logout on sign out", async () => { - const fetchMock = mockFetch({ - "/auth/me": { signedIn: true, displayName: "Ada" }, - "POST /auth/logout": { ok: true }, - }); - vi.stubGlobal("fetch", fetchMock); - render(createElement(SignIn)); - fireEvent.click(await screen.findByTitle("Sign out")); - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith("/auth/logout", { method: "POST" }), - ); - }); -}); diff --git a/tests/unit/components/header-menu.test.tsx b/tests/unit/components/header-menu.test.tsx new file mode 100644 index 00000000..c6fc5f21 --- /dev/null +++ b/tests/unit/components/header-menu.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, waitFor, fireEvent } from "@testing-library/react"; +import { createElement } from "react"; +import { renderWithDocument } from "../../helpers/document-context"; +import HeaderMenu from "~/components/HeaderMenu"; + +function mockFetch(routes: Record) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const key = `${init?.method ?? "GET"} ${new URL(url, "https://vapor.fyi").pathname}`; + const body = routes[key] ?? routes[new URL(url, "https://vapor.fyi").pathname] ?? {}; + return { ok: true, json: async () => body } as Response; + }); +} + +describe("HeaderMenu", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch({ "/auth/me": { signedIn: false } })); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("opens with status, Agents, and theme rows", async () => { + const onOpenAgents = vi.fn(); + renderWithDocument(createElement(HeaderMenu, { onOpenAgents })); + fireEvent.click(screen.getByLabelText("Menu")); + + expect(screen.getByText("Agents")).toBeTruthy(); + expect(screen.getByText("Theme")).toBeTruthy(); + expect(screen.getByText("Connecting")).toBeTruthy(); + expect(screen.getByLabelText("Light")).toBeTruthy(); + expect(screen.getByLabelText("Dark")).toBeTruthy(); + expect(screen.getByLabelText("Auto")).toBeTruthy(); + }); + + it("Agents row closes the menu and opens the panel", () => { + const onOpenAgents = vi.fn(); + renderWithDocument(createElement(HeaderMenu, { onOpenAgents })); + fireEvent.click(screen.getByLabelText("Menu")); + fireEvent.click(screen.getByText("Agents")); + expect(onOpenAgents).toHaveBeenCalledOnce(); + expect(screen.queryByText("Theme")).toBeFalsy(); + }); + + it("shows display name and sign-out when signed in", async () => { + const fetchMock = mockFetch({ + "/auth/me": { signedIn: true, displayName: "Ada" }, + "POST /auth/logout": { ok: true }, + }); + vi.stubGlobal("fetch", fetchMock); + renderWithDocument(createElement(HeaderMenu, { onOpenAgents: vi.fn() })); + fireEvent.click(screen.getByLabelText("Menu")); + + expect(await screen.findByText("Ada")).toBeTruthy(); + fireEvent.click(screen.getByLabelText("Sign out")); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/auth/logout", { method: "POST" }), + ); + }); +}); diff --git a/tests/unit/components/mobile-panel.test.tsx b/tests/unit/components/mobile-panel.test.tsx index fdff5931..f27de261 100644 --- a/tests/unit/components/mobile-panel.test.tsx +++ b/tests/unit/components/mobile-panel.test.tsx @@ -23,8 +23,8 @@ describe("MobilePanel", () => { { context: { mode: "suggest" } }, ); - // Editing tab starts active, should show ModeToggle content - expect(queryByText("Suggest changes")).toBeTruthy(); + // Editing tab starts active; suggest mode shows the cursor actions + expect(queryByText("Accept")).toBeTruthy(); // Click Comments tab fireEvent.click(getByText("Comments")); @@ -35,15 +35,14 @@ describe("MobilePanel", () => { expect(queryByText("Comments (0)")).toBeFalsy(); }); - it("editing tab renders ModeToggle and SuggestionActions", () => { - const { getByText, getByLabelText } = renderWithDocument( + it("editing tab renders SuggestionActions in suggest mode", () => { + const { getByText } = renderWithDocument( createElement(MobilePanel, { className: "lg:hidden" }), + { context: { mode: "suggest" } }, ); - // Editing tab is active by default - // ModeToggle shows "Edit mode" when mode is "edit" - expect(getByText("Edit mode")).toBeTruthy(); - expect(getByLabelText("Toggle suggest mode")).toBeTruthy(); + expect(getByText("Accept")).toBeTruthy(); + expect(getByText("Reject")).toBeTruthy(); }); it("comments tab renders CommentInput and ThreadList", () => { diff --git a/tests/unit/components/mode-menu.test.tsx b/tests/unit/components/mode-menu.test.tsx new file mode 100644 index 00000000..baaf31bb --- /dev/null +++ b/tests/unit/components/mode-menu.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { createElement } from "react"; +import { renderWithDocument } from "../../helpers/document-context"; +import ModeMenu from "~/components/ModeMenu"; + +describe("ModeMenu", () => { + it("trigger shows Edit when mode is edit", () => { + const { getByLabelText } = renderWithDocument(createElement(ModeMenu), { + context: { mode: "edit" }, + }); + expect(getByLabelText("Editing mode").textContent).toContain("Edit"); + }); + + it("trigger shows Suggest when mode is suggest", () => { + const { getByLabelText } = renderWithDocument(createElement(ModeMenu), { + context: { mode: "suggest" }, + }); + expect(getByLabelText("Editing mode").textContent).toContain("Suggest"); + }); +}); diff --git a/tests/unit/components/mode-toggle.test.tsx b/tests/unit/components/mode-toggle.test.tsx deleted file mode 100644 index 3c16daa2..00000000 --- a/tests/unit/components/mode-toggle.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect } from "vitest"; -import { createElement } from "react"; -import { fireEvent } from "@testing-library/react"; -import { renderWithDocument } from "../../helpers/document-context"; -import ModeToggle from "~/components/ModeToggle"; - -describe("ModeToggle", () => { - it("shows 'Edit mode' when mode is edit", () => { - const { getByText } = renderWithDocument(createElement(ModeToggle), { - context: { mode: "edit" }, - }); - expect(getByText("Edit mode")).toBeTruthy(); - }); - - it("shows 'Suggest changes' when mode is suggest", () => { - const { getByText } = renderWithDocument(createElement(ModeToggle), { - context: { mode: "suggest" }, - }); - expect(getByText("Suggest changes")).toBeTruthy(); - }); - - it("toggle calls toggleMode", () => { - const { contextValue, getByLabelText } = renderWithDocument( - createElement(ModeToggle), - ); - fireEvent.click(getByLabelText("Toggle suggest mode")); - expect(contextValue.toggleMode).toHaveBeenCalledOnce(); - }); -}); diff --git a/tests/unit/components/remaining.test.tsx b/tests/unit/components/remaining.test.tsx index 0274266f..a9d4957c 100644 --- a/tests/unit/components/remaining.test.tsx +++ b/tests/unit/components/remaining.test.tsx @@ -34,8 +34,6 @@ describe("SuggestionActions", () => { ); expect(getByText("Accept")).toBeTruthy(); expect(getByText("Reject")).toBeTruthy(); - expect(getByText("Accept all")).toBeTruthy(); - expect(getByText("Reject all")).toBeTruthy(); }); }); diff --git a/tests/unit/components/thread-panel.test.tsx b/tests/unit/components/thread-panel.test.tsx index 09f32577..74ff384a 100644 --- a/tests/unit/components/thread-panel.test.tsx +++ b/tests/unit/components/thread-panel.test.tsx @@ -31,16 +31,13 @@ describe("ThreadPanel", () => { onDelete: vi.fn(), }); - it("renders author name and comment text without color dot", () => { + it("renders author name, timestamp, and comment text", () => { const props = defaultProps(); - const { getByText, container } = render(createElement(ThreadPanel, props)); + const { getByText } = render(createElement(ThreadPanel, props)); expect(getByText("Alice")).toBeTruthy(); + expect(getByText("just now")).toBeTruthy(); expect(getByText("Test comment")).toBeTruthy(); - - // No color dot span - const dots = container.querySelectorAll(".rounded-full"); - expect(dots).toHaveLength(0); }); it("applies bg-border/50 when active", () => { @@ -87,7 +84,7 @@ describe("ThreadPanel", () => { expect(highlight.className).toContain("text-base"); }); - it("renders replies with vertical border line and no color dots", () => { + it("renders replies with author header and text", () => { const props = { ...defaultProps(), thread: makeThread({ @@ -101,38 +98,33 @@ describe("ThreadPanel", () => { ], }), }; - const { getByText, container } = render(createElement(ThreadPanel, props)); + const { getByText } = render(createElement(ThreadPanel, props)); expect(getByText("Bob")).toBeTruthy(); expect(getByText("A reply")).toBeTruthy(); - - // Replies container has border-l - const repliesContainer = container.querySelector(".border-l.border-border.pl-3"); - expect(repliesContainer).toBeTruthy(); - - // No color dots - const dots = container.querySelectorAll(".rounded-full"); - expect(dots).toHaveLength(0); }); - it("renders action button bar with bordered styling", () => { + it("renders resolve and overflow icons in the header", () => { const props = defaultProps(); - const { getByText } = render(createElement(ThreadPanel, props)); + const { getByLabelText } = render(createElement(ThreadPanel, props)); - const replyBtn = getByText("Reply"); - const resolveBtn = getByText("Resolve"); - const deleteBtn = getByText("Delete"); + const resolveBtn = getByLabelText("Resolve"); + const moreBtn = getByLabelText("More actions"); - // Check button styling classes - expect(replyBtn.className).toContain("uppercase"); - expect(replyBtn.className).toContain("tracking-wider"); - expect(resolveBtn.className).toContain("text-green-600"); - expect(deleteBtn.className).toContain("text-red-500"); + expect(resolveBtn.querySelector(".material-symbols-outlined")).toBeTruthy(); + expect(moreBtn.querySelector(".material-symbols-outlined")).toBeTruthy(); + }); + + it("Delete lives in the overflow menu", () => { + const props = defaultProps(); + const { getByLabelText, getByText, queryByText } = render( + createElement(ThreadPanel, props), + ); - // Parent bar has border - const bar = replyBtn.parentElement!; - expect(bar.className).toContain("border"); - expect(bar.className).toContain("border-border"); + expect(queryByText("Delete")).toBeFalsy(); + fireEvent.click(getByLabelText("More actions")); + fireEvent.click(getByText("Delete")); + expect(props.onDelete).toHaveBeenCalledWith("t1"); }); it("resolve button shows Reopen for resolved thread", () => { @@ -140,30 +132,24 @@ describe("ThreadPanel", () => { ...defaultProps(), thread: makeThread({ resolved: true }), }; - const { getByText } = render(createElement(ThreadPanel, props)); - expect(getByText("Reopen")).toBeTruthy(); + const { getByLabelText } = render(createElement(ThreadPanel, props)); + expect(getByLabelText("Reopen")).toBeTruthy(); }); - it("action buttons call correct handlers", () => { + it("resolve calls onResolve", () => { const props = defaultProps(); - const { getByText } = render(createElement(ThreadPanel, props)); + const { getByLabelText } = render(createElement(ThreadPanel, props)); - fireEvent.click(getByText("Resolve")); + fireEvent.click(getByLabelText("Resolve")); expect(props.onResolve).toHaveBeenCalledWith("t1"); - - fireEvent.click(getByText("Delete")); - expect(props.onDelete).toHaveBeenCalledWith("t1"); }); - it("reply input appears on Reply click and submits on Enter", () => { + it("reply pill is always visible and submits on Enter", () => { const props = defaultProps(); - const { getByText, getByPlaceholderText } = render( - createElement(ThreadPanel, props), - ); + const { getByPlaceholderText } = render(createElement(ThreadPanel, props)); - fireEvent.click(getByText("Reply")); const input = getByPlaceholderText("Reply..."); - expect(input).toBeTruthy(); + expect(input.className).toContain("rounded-full"); fireEvent.change(input, { target: { value: "My reply" } }); fireEvent.keyDown(input, { key: "Enter" }); @@ -172,9 +158,9 @@ describe("ThreadPanel", () => { it("action button clicks do not trigger thread selection", () => { const props = defaultProps(); - const { getByText } = render(createElement(ThreadPanel, props)); + const { getByLabelText } = render(createElement(ThreadPanel, props)); - fireEvent.click(getByText("Resolve")); + fireEvent.click(getByLabelText("Resolve")); expect(props.onSelect).not.toHaveBeenCalled(); }); }); From 907014d7b3b750cb0722eb6c93ec705db498a8cb Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:58:48 -0700 Subject: [PATCH 62/83] Add vector logo as primary favicon Co-Authored-By: Claude Fable 5 --- app/root.tsx | 1 + public/logo.svg | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 public/logo.svg diff --git a/app/root.tsx b/app/root.tsx index 0426c1e7..96a8ff27 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -13,6 +13,7 @@ import Fathom from "~/components/Fathom"; import "./app.css"; export const links: Route.LinksFunction = () => [ + { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }, { rel: "icon", href: "/favicon.ico", sizes: "48x48" }, { rel: "icon", type: "image/png", href: "/favicon-32.png", sizes: "32x32" }, { rel: "apple-touch-icon", href: "/apple-touch-icon.png" }, diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..88f2840e --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + From 774a4c7111e0a25edf92000bdf0612339327417d Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:13:26 -0700 Subject: [PATCH 63/83] Simplify the comment rail and fold Preview into the mode menu - Start Editing deletes onboarding comments for good: the 3s fallback timer in useThreads now re-scans the document before recreating a thread, so marks cleared in the meantime stay gone - Drop Accept/Reject buttons from the right column (menu has Accept all / Reject all; the bubble toolbar covers single suggestions) - Drop the comments header (count, + Add) and the empty state - Preview moves under the mode menu alongside Edit and Suggest; the big sidebar Preview button is gone (mobile keeps its tab) - Drop the border between the editor and the comment rail Co-Authored-By: Claude Fable 5 --- app/components/MobilePanel.tsx | 2 - app/components/ModeMenu.tsx | 34 +++++++--- app/components/SuggestionActions.tsx | 69 --------------------- app/components/ThreadList.tsx | 20 ------ app/lib/useThreads.ts | 4 ++ app/root.tsx | 2 +- app/routes/demo.md | 2 +- app/routes/doc.$id.tsx | 9 +-- tests/unit/components/mobile-panel.test.tsx | 28 ++++----- tests/unit/components/remaining.test.tsx | 12 ---- tests/unit/components/thread-list.test.tsx | 18 +----- 11 files changed, 47 insertions(+), 153 deletions(-) delete mode 100644 app/components/SuggestionActions.tsx diff --git a/app/components/MobilePanel.tsx b/app/components/MobilePanel.tsx index 694d0162..0726e195 100644 --- a/app/components/MobilePanel.tsx +++ b/app/components/MobilePanel.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useRef } from "react"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; -import SuggestionActions from "~/components/SuggestionActions"; import PreviewToggle from "~/components/PreviewToggle"; import OnboardingBanner from "~/components/OnboardingBanner"; import { useDocument } from "~/lib/DocumentContext"; @@ -59,7 +58,6 @@ export default function MobilePanel({ className }: { className?: string }) { {activeTab === "editing" && ( <> - )} {activeTab === "comments" && ( diff --git a/app/components/ModeMenu.tsx b/app/components/ModeMenu.tsx index ce277d02..9f9ec7c4 100644 --- a/app/components/ModeMenu.tsx +++ b/app/components/ModeMenu.tsx @@ -13,11 +13,12 @@ function ChevronDown() { } /** - * Header menu for the editing mode. Edit and Suggest switch modes; - * Accept all / Reject all apply to every pending suggestion. + * Header menu for the editing mode. Edit and Suggest switch modes, Preview + * toggles the rendered view; Accept all / Reject all apply to every + * pending suggestion. */ export default function ModeMenu() { - const { editorInstance: editor, mode, setMode } = useDocument(); + const { editorInstance: editor, mode, setMode, showPreview, togglePreview } = useDocument(); const [hasSuggestions, setHasSuggestions] = useState(false); useEffect(() => { @@ -40,7 +41,7 @@ export default function ModeMenu() { className="flex h-full cursor-pointer items-center gap-1 px-3 text-sm uppercase tracking-wider transition-colors hover:bg-border" aria-label="Editing mode" > - {mode === "suggest" ? "Suggest" : "Edit"} + {showPreview ? "Preview" : mode === "suggest" ? "Suggest" : "Edit"} @@ -50,15 +51,32 @@ export default function ModeMenu() { align="end" sideOffset={4} > - setMode("edit")} className={itemClass}> + { + setMode("edit"); + if (showPreview) togglePreview(); + }} + className={itemClass} + > Edit - {mode === "edit" && {"✓"}} + {mode === "edit" && !showPreview && {"✓"}} - setMode("suggest")} className={itemClass}> + { + setMode("suggest"); + if (showPreview) togglePreview(); + }} + className={itemClass} + > Suggest - {mode === "suggest" && {"✓"}} + {mode === "suggest" && !showPreview && {"✓"}} + + + + Preview + {showPreview && {"✓"}} { - if (!editor) return; - const updateSuggestions = () => setHasSuggestions(hasSuggestionMarkup(editor)); - const updateCursor = () => setCursorInRange(isCursorInSuggestion(editor)); - const update = () => { - updateSuggestions(); - updateCursor(); - }; - update(); - editor.on("update", update); - editor.on("selectionUpdate", updateCursor); - return () => { - editor.off("update", update); - editor.off("selectionUpdate", updateCursor); - }; - }, [editor]); - - const handleAcceptAtCursor = useCallback(() => { - if (!editor) return; - processRangeAtCursor(editor, true); - }, [editor]); - - const handleRejectAtCursor = useCallback(() => { - if (!editor) return; - processRangeAtCursor(editor, false); - }, [editor]); - - const isSuggest = mode === "suggest"; - - // In edit mode, hide when no suggestions. In suggest mode, always show. - if (!isSuggest && !hasSuggestions) return null; - - const enabledClass = - "flex-1 cursor-pointer border border-border px-2 py-1.5 text-sm uppercase tracking-wider text-muted transition-colors hover:bg-border"; - const disabledClass = - "flex-1 cursor-default border border-border px-2 py-1.5 text-sm uppercase tracking-wider text-muted/40 transition-colors"; - - return ( -

- ); -} diff --git a/app/components/ThreadList.tsx b/app/components/ThreadList.tsx index 18009bea..e2ef780b 100644 --- a/app/components/ThreadList.tsx +++ b/app/components/ThreadList.tsx @@ -10,7 +10,6 @@ export default function ThreadList() { addReply: onReply, resolveThread: onResolve, deleteThread: onDelete, - openCommentInput: onNewComment, } = useDocument(); const [showResolved, setShowResolved] = useState(false); @@ -21,25 +20,6 @@ export default function ThreadList() { return (
-
- - Comments ({openThreads.length}) - - -
- - {visibleThreads.length === 0 && !showResolved && ( -
- No comments yet -
- )} - {visibleThreads.map((thread) => (
t.commentText === comment.commentText)) return; + // Re-scan: the mark may be gone by now (e.g. Start Editing + // cleared the onboarding doc). Ground truth is the document. + const live = scanDocumentComments(editor); + if (!live.some((c) => c.commentText === comment.commentText)) return; reconcilingRef.current = true; threadsMapRef.current.set( id, diff --git a/app/root.tsx b/app/root.tsx index 96a8ff27..c53ecc79 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -37,7 +37,7 @@ export const links: Route.LinksFunction = () => [ // Subset to the icon names actually used — keep this list sorted and in // sync with usages or new glyphs render as raw text. rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=account_circle,brightness_auto,check,dark_mode,delete,done_all,edit,light_mode,logout,more_vert,rate_review,remove_done,smart_toy,undo&display=block", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=account_circle,brightness_auto,check,dark_mode,delete,done_all,edit,light_mode,logout,more_vert,rate_review,remove_done,smart_toy,undo,visibility&display=block", }, ]; diff --git a/app/routes/demo.md b/app/routes/demo.md index 49260a5f..4a9b01f9 100644 --- a/app/routes/demo.md +++ b/app/routes/demo.md @@ -47,7 +47,7 @@ Switch from **Edit Mode** to **Suggest Changes** in the sidebar (or bottom panel Comments can be anchored to a {==good==}{>>Should we use a stronger word here?<<} span of text using highlights, or placed inline without a selection. -Use the bubble menu to add a comment to a highlight or hit the `+ Add` button in the comments pane. {>>This paragraph needs a citation.<<} _(Comments can also be added without a highlight)_. +Select some text and use the bubble menu to add a comment to it. {>>This paragraph needs a citation.<<} _(Comments can also be added without a highlight)_. Click on a highlighted region or comment to open the thread panel. Threads support replies and can be resolved when the discussion is complete. diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index 6dced2cd..1cdbeb3e 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -9,13 +9,11 @@ import { useYjsEditor } from "~/lib/useYjsEditor"; import { DocumentProvider, useDocument } from "~/lib/DocumentContext"; import Editor from "~/components/Editor"; import Preview from "~/components/Preview"; -import PreviewToggle from "~/components/PreviewToggle"; import ShareButton from "~/components/ShareButton"; import AgentsPanel from "~/components/AgentsPanel"; import ModeMenu from "~/components/ModeMenu"; import HeaderMenu from "~/components/HeaderMenu"; import CleanViewToggle from "~/components/CleanViewToggle"; -import SuggestionActions from "~/components/SuggestionActions"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; import MobilePanel from "~/components/MobilePanel"; @@ -118,7 +116,7 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul setAgentsOpen(false)} />
-
+
- {/* Highlight context */} - {thread.highlightText && ( -
- {truncate(thread.highlightText, 80)} -
- )} - {/* Comment text */}

{thread.commentText}

diff --git a/tests/unit/components/thread-panel.test.tsx b/tests/unit/components/thread-panel.test.tsx index 74ff384a..8df77f0b 100644 --- a/tests/unit/components/thread-panel.test.tsx +++ b/tests/unit/components/thread-panel.test.tsx @@ -73,15 +73,14 @@ describe("ThreadPanel", () => { expect(props.onSelect).toHaveBeenCalledWith("t1"); }); - it("renders highlight context with text-base", () => { + it("does not repeat the highlighted phrase in the card", () => { const props = { ...defaultProps(), thread: makeThread({ highlightText: "Some highlighted text" }), }; - const { getByText } = render(createElement(ThreadPanel, props)); + const { queryByText } = render(createElement(ThreadPanel, props)); - const highlight = getByText("Some highlighted text"); - expect(highlight.className).toContain("text-base"); + expect(queryByText("Some highlighted text")).toBeFalsy(); }); it("renders replies with author header and text", () => { From ca951d22f61bb4fe51cfa1750983e6bfbd62ef33 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:19:03 -0700 Subject: [PATCH 65/83] Plan the notes-app import: WYSIWYG, toolbar, UI kit, styles, roadmap Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-31-editor-styles-plan.md | 41 ++++++++++++++++ .../2026-08-31-formatting-toolbar-plan.md | 42 +++++++++++++++++ .../2026-08-31-notes-features-roadmap.md | 42 +++++++++++++++++ .../plans/2026-08-31-notes-import-overview.md | 31 ++++++++++++ .../plans/2026-08-31-ui-component-kit-plan.md | 44 +++++++++++++++++ docs/plans/2026-08-31-wysiwyg-editing-plan.md | 47 +++++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 docs/plans/2026-08-31-editor-styles-plan.md create mode 100644 docs/plans/2026-08-31-formatting-toolbar-plan.md create mode 100644 docs/plans/2026-08-31-notes-features-roadmap.md create mode 100644 docs/plans/2026-08-31-notes-import-overview.md create mode 100644 docs/plans/2026-08-31-ui-component-kit-plan.md create mode 100644 docs/plans/2026-08-31-wysiwyg-editing-plan.md diff --git a/docs/plans/2026-08-31-editor-styles-plan.md b/docs/plans/2026-08-31-editor-styles-plan.md new file mode 100644 index 00000000..ab83ac50 --- /dev/null +++ b/docs/plans/2026-08-31-editor-styles-plan.md @@ -0,0 +1,41 @@ +# Editor styles and formatting defaults + +**Goal:** Adopt the notes app's typographic defaults and semantic palette so vapor documents read like finished pages, in both the current markdown view and the coming WYSIWYG view. + +**Part of the [notes-app import series](2026-08-31-notes-import-overview.md).** Independent; pairs with plan 1 (which introduces the token *names* — this plan owns their *values* and the content styles). + +## What we're importing + +The `.tiptap` stylesheet from `notes/src/globals.css`, adapted to vapor's class hooks: + +- **Heading scale**: H1 1.875rem, H2 1.5rem, H3 1.25rem, bold, with top margins (1.5/1.25/1rem) and `margin-top: 0` on first child. vapor's current `md-heading-*` classes (1.75/1.4/1.15em, weight 600) are replaced by these values so the two eras match. +- **Block rhythm**: paragraphs `margin-bottom: 0.5rem` (vapor currently has `margin: 0` — the single biggest visual change), list `margin-left: 1.5rem` + item spacing, blockquote with 4px border-left + italic + muted color. +- **Code**: inline code on `--muted` background with 0.25rem radius; `pre` blocks padded 1rem with horizontal scroll; the one-dark-ish hljs palette (keyword `#c678dd`, string `#98c379`, number `#d19a66`, function `#61afef`, built-in `#e6c07b`) replacing vapor's current sugar-high colors — map the palette onto vapor's `sh-*` classes now, `hljs-*` after plan 3 swaps highlighters. +- **Tables and task lists**: full cell/border/header treatment and checkbox list layout — land the CSS now (inert), used when plan 3 introduces the nodes. +- **Placeholder**: `is-editor-empty::before` pattern for the empty-document hint. + +## Decisions + +- **Semantic token values.** Define the palette introduced in plan 1 concretely, staying vapor: `--color-paper`/`--color-ink` remain the ground truth; `card` = paper, `popover` = paper, `muted` = current border gray as a *background* role plus `muted-foreground` = current `--color-muted`, `accent` = 8% ink over paper, `primary` = ink, `destructive` = the red already used for deletions. Canary/coral/chartreuse stay as vapor's accent identity — the notes app's palette is neutral and doesn't override brand color. +- **Keep vapor's fonts.** The notes app inherits system fonts too; no font change. Body size stays 1.15rem/1.6 in the editor. +- **Dark mode by token only.** All new styles reference tokens; the existing `[data-theme="dark"]` and `@media (prefers-color-scheme: dark) [data-theme="auto"]` blocks gain the new token overrides and *no* per-component rules. The sidekick-block purple treatment (roadmap item) is the only styled-both-ways special case and ships with that feature, not here. +- **Preview and editor converge.** The `.preview` stylesheet adopts the same scale/spacing so toggling Preview stops changing the type ramp. After plan 3, most of `.preview` collapses into `.tiptap` rules. + +## Tasks + +1. Set the semantic token values (light + dark + auto) in `app.css`; verify every token resolves in all three theme states (the un-stamped default is `data-theme="auto"` here, which vapor stamps explicitly — both dark paths covered). +2. Replace the `md-heading-*`, paragraph, and list styles in the `.tiptap` block with the imported scale and rhythm; port blockquote, inline-code, and pre styles onto vapor's `md-code` / `md-code-block` hooks. +3. Land table/task-list CSS (dormant until plan 3) and the placeholder rule. +4. Align `.preview` to the same scale; delete rules that become duplicates. +5. Update the highlight palette on `sh-*` classes; keep the dark variants. +6. Visual pass in the pane: onboarding doc + the formatting showcase doc pattern (`/mcws2erh` content) in light, dark, and auto; comment underlines, suggestions, and cursors unchanged. + +## Regression surfaces + +- Paragraph `margin-bottom` changes every doc's vertical rhythm — check comment-anchor click targets and the point-comment marker alignment (`.cm-point-marker` uses em-based offsets). +- `max-width: 65ch` on `.tiptap p` must survive (reading measure). +- Tests that assert class names (`md-heading` etc.) — none assert values, so CSS-only changes should pass untouched; `git grep` before assuming. + +## Out of scope + +Component chrome (plan 1), any schema/node changes (plan 3), sidekick-block styling (roadmap). diff --git a/docs/plans/2026-08-31-formatting-toolbar-plan.md b/docs/plans/2026-08-31-formatting-toolbar-plan.md new file mode 100644 index 00000000..f88ff00f --- /dev/null +++ b/docs/plans/2026-08-31-formatting-toolbar-plan.md @@ -0,0 +1,42 @@ +# Formatting toolbar + +**Goal:** A formatting toolbar in the document header, imported from the notes app's grouped-menu design: Format, Lists, and Insert menus plus a contextual Table menu, wired to the rich editor. + +**Part of the [notes-app import series](2026-08-31-notes-import-overview.md).** Depends on plan 1 (Menu/Button/Icon kit) and plan 3 (rich commands exist). The existing bubble toolbar stays for selection-scoped actions (comment, suggest accept/reject). + +## Source design being imported + +From `notes/src/components/NoteEditor.tsx` `MenuBar`: + +- **Format menu** (trigger icon `format_size`): a horizontal B / I / U / S icon-button row at the top of the menu (active state = `bg-accent`), then items Body Text, Heading 1–3, Code — each with icon + active indication. +- **Lists menu** (`format_list_bulleted`): Bullet List, Numbered List, Checkbox List, Quote. +- **Insert menu** (`add_box`): Link to… (dialog), Divider, Code Block (toggles selection or inserts empty fence), Table (3×3 with header, disabled inside a table). Attach File and Sidekick block are roadmap features — the menu ships without them and gains items as those land. +- **Table menu**: rendered only when `editor.isActive("table")` — add/delete column, add/delete row, delete table. +- **Fade behavior**: the source toolbar sits at 30% opacity unless the editor is focused, the toolbar is hovered, or a menu is open. + +## Decisions + +- **Placement: a group in the existing header**, between the document id and the ModeMenu — vapor's header is already the command surface and horizontally scrolls on mobile. No second toolbar row; `min-h` stays as-is. +- **Fade imports as muting, not vanishing.** The header carries navigation (vapor link, id, expiry) that must never fade. The *formatting group only* dims to `opacity-40` when the editor is unfocused, restoring on hover/focus/open-menu — the source's `openMenus` counter pattern comes along (menus outlive toolbar hover). +- **Link dialog, vapor-flavored.** The source dialog searches the user's other notes; vapor has no cross-document index, so v1 is URL + optional title over the current selection (insert-or-wrap logic imported as-is). An inter-document search belongs to the roadmap's document-linking item. +- **Edit vs. suggest aware.** Formatting commands run through the same suggest-mode interception as typing: in suggest mode, toggling bold over a range produces a tracked change, not a silent mutation. This falls out of plan 3's suggest plugin if command transactions route through it — verify explicitly; it is the one behavior with no source-app precedent. +- **Task/checkbox item ships disabled** until the task-list UI (roadmap) lands, or is omitted from v1 — decide at implementation by whether plan 3 enabled the nodes with usable defaults. +- **Icons**: extend the Material Symbols subset in [root.tsx](../../app/root.tsx) with `format_size, format_bold, format_italic, format_underlined, strikethrough_s, format_paragraph, format_h1, format_h2, format_h3, format_list_bulleted, format_list_numbered, check_box, format_quote, add_box, link, horizontal_rule, code, table, table_rows, view_column, add` (keep sorted). + +## Tasks + +1. `app/components/FormatToolbar.tsx`: the three menus + table menu as a single component using the plan-1 kit; active-state styling from `editor.isActive(...)`; editor from `useDocument()`. +2. Focus/hover fade state (lift `isFocused` tracking from Editor into context or use `editor.isFocused`). +3. Link dialog component (kit `Input` + `Button`; imported insert-or-wrap selection logic). +4. Header wiring in [doc.$id.tsx](../../app/routes/doc.$id.tsx); mobile check that the scrolling header stays usable with the added group. +5. Keyboard shortcuts that pair with the toolbar (⌘B/⌘I already via StarterKit; add ⌘U underline, ⌘⇧X strike if missing). +6. Suggest-mode formatting verification (tracked-change toggling) with tests. +7. Tests: menu contents render; commands dispatch (mock editor per existing patterns); active states reflect `isActive`. + +## Regression surfaces + +Header overflow scrolling; menu portals over the editor (z-order with bubble toolbar and thread rail); no focus steal from the editor when opening menus (`editor.chain().focus()` on every command, as the source does). + +## Out of scope + +Attachments, sidekick block, note-search linking, version history (all roadmap); bubble-toolbar changes. diff --git a/docs/plans/2026-08-31-notes-features-roadmap.md b/docs/plans/2026-08-31-notes-features-roadmap.md new file mode 100644 index 00000000..f833e3bb --- /dev/null +++ b/docs/plans/2026-08-31-notes-features-roadmap.md @@ -0,0 +1,42 @@ +# Notes-app features roadmap + +**Part of the [notes-app import series](2026-08-31-notes-import-overview.md).** Everything notable in the source app beyond the four core asks, assessed for vapor and scheduled. Each shipped item gets its own plan when picked up. + +## Tier 1 — schedule next (high fit, bounded scope) + +**Keyboard shortcut suite** (`notes/src/lib/editor-shortcuts.ts`, portable nearly verbatim after WYSIWYG): +- Tab / Shift-Tab ladder: H1→H2→H3→Body→Bullet and back; in lists, sink/lift the item. +- ⌘⌃↑ / ⌘⌃↓ move block; ⌘D duplicate block; ⌘Enter / ⌘⇧Enter insert paragraph below/above. +- ⌘\ clear formatting; ⌘⌥0 to paragraph; ⌘⌥C code block; arrow input rules (`—>` → `→`). +- vapor addition: every shortcut must respect suggest mode (block moves become tracked operations or are disabled in suggest — decide in the plan). + +**Smart link paste** (`handlePaste` in the source editor): pasting a URL over selected text links the selection; pasting bare URLs with HTML clipboard data extracts the page title into linked text. Small, self-contained, high daily value. + +**Code block language selector** (`code-block-view.tsx`): React node view overlaying a quiet `` in `ThreadPanel`, `CommentInput`, `AgentsPanel`, `HeaderMenu`, `SignIn`-popover rows to `Button`/`Input` where it doesn't change layout semantics (flush toolbar buttons keep custom classes via `className`). +5. Remove `@radix-ui/react-dropdown-menu` and `@radix-ui/react-switch` from package.json once no imports remain. +6. Tests: unit tests for Button variant/size classes and Menu open/close + destructive item; existing component tests keep passing unchanged (they assert labels, not implementation). + +## Regression surfaces + +- Menu open/close in jsdom (Base UI trigger events differ from Radix — verify `fireEvent.click` opens it; if not, tests target the trigger render only, as today). +- SSR hydration: Base UI portals on the doc route (ThemeSelector's mounted-gate pattern is the fallback if Base UI menus mismatch). +- The header's horizontal scroll: menu triggers must stay flush (`h-full` buttons, no wrapping). + +## Out of scope + +Editor styling (plan 2), toolbar composition (plan 4), any new controls. diff --git a/docs/plans/2026-08-31-wysiwyg-editing-plan.md b/docs/plans/2026-08-31-wysiwyg-editing-plan.md new file mode 100644 index 00000000..3a071114 --- /dev/null +++ b/docs/plans/2026-08-31-wysiwyg-editing-plan.md @@ -0,0 +1,47 @@ +# WYSIWYG editing as the default + +**Goal:** Documents render rich by default — headings, lists, quotes, code blocks as real nodes, no visible markdown syntax — while markdown remains the storage-interchange format for exports, raw endpoints, and agents. + +**Part of the [notes-app import series](2026-08-31-notes-import-overview.md).** The largest plan; plans 1–2 should land first. Plan 4 (toolbar) builds directly on this. + +## Where vapor is vs. where the notes app is + +vapor today: the Yjs fragment is a flat list of `paragraph` elements, one per markdown *line*, whose text is the literal markdown (`**bold**`, `# Heading`, `{++added++}`). [markdown-decorations.ts](../../app/lib/markdown-decorations.ts) styles the syntax in place; [critic-parser/serializer](../../app/lib/critic-parser.ts) translate CriticMarkup text ↔ ProseMirror marks; [y-markdown.ts](../../app/lib/y-markdown.ts) reads blocks server-side by concatenating text runs and re-wrapping critic delimiters; `blockHash` anchors hash that literal text. + +The notes app: TipTap StarterKit nodes edited rich; markdown only at the boundary (`getMarkdown()` / `setContent(md)`). + +The import is therefore **a document-model change**, not a rendering toggle: the shared Yjs fragment starts holding real heading/list/quote/code nodes, and every consumer of "block text" moves to a serializer. + +## Decisions + +- **The CRDT holds rich nodes.** Schema: StarterKit (heading 1–3, bullet/ordered lists, blockquote, codeBlock, horizontalRule, hardBreak) + vapor's critic marks + collaboration/caret. Tables, task lists, underline are *enabled in the schema* from day one (so the doc format doesn't change again) but get UI only in plan 4 / roadmap. +- **One serialization module, shared client/server.** New `app/shared/rich-markdown.ts` built on `prosemirror-model` + `prosemirror-markdown` (pure JS — runs in Workers): a schema instance, a `MarkdownParser` and `MarkdownSerializer` extended with CriticMarkup delimiters for the four critic marks. Converts via `y-prosemirror` helpers (`yXmlFragmentToProseMirrorRootNode`, `prosemirrorToYXmlFragment`). This **replaces `y-markdown.ts`** and the import/export halves of critic-parser/serializer (the parser stays for `/new` ingestion of critic syntax). +- **Anchors hash the block's markdown.** `blockHash` is computed over each top-level node's markdown serialization — content-derived, identical on every client, and stable for unchanged blocks. `read_document` returns markdown per block. **`suggest.find` matches plain text** (`node.textContent`), because agents quote what they read and offsets must map to document positions; tool descriptions updated to say so. +- **No literal critic delimiters in the doc.** Suggestions and comments exist purely as marks; `{++…++}` appears only in exports and raw endpoints. Consequences: `markdown-decorations.ts` and the `cm-delimiter` widgets are deleted; **clean view is retired** (there is no markup to hide — `CleanViewToggle` goes away). +- **Preview becomes Source.** WYSIWYG makes the rendered preview redundant. The mode menu's Preview item becomes **Markdown** — a read-only view of the serialized markdown (the inverse of today). `P`-hold keeps working, showing source. +- **Typing performance engine goes block-structured.** Agent inserts parse markdown → nodes; the engine appends each block element, then types its text run-by-run *with formatting attributes* (`Y.XmlText.insert(idx, text, attrs)`), so styled text styles while typing. Multi-block inserts animate block-by-block — this also delivers the previously approved fix for multi-paragraph inserts skipping animation, and pace retunes to ~40–70 WPM in the same change. +- **Old documents are not migrated.** A pre-change doc opens as flat paragraphs of literal markdown text in the new schema (valid, just unstyled) and expires within 99 hours. The onboarding template is re-imported through the new parser at creation, so new docs are born rich. + +## Phases + +**A. Serialization core (server-safe, test-heavy).** `rich-markdown.ts` with round-trip property tests: markdown → nodes → markdown stable for the whole feature matrix (headings, nested lists, quotes, fenced code with language, hr, inline marks, links, critic syntax, mixed nesting). Port `getBlocks`/`yDocToMarkdown`/`buildMarkdownBlocks`/insert helpers onto it. Delete `y-markdown.ts`. + +**B. Client editor.** Enable StarterKit nodes in [useYjsEditor.ts](../../app/lib/useYjsEditor.ts) / Editor extensions; remove markdown-decorations and delimiter CSS; wire markdown paste (clipboard markdown → parsed nodes) and `/new` body ingestion through the parser; input rules (`#`, `-`, `1.`, `` ``` ``, `>`) + Typography. Suggest-mode plugin re-verified over rich nodes (marks apply across node boundaries — the existing `inclusive: false` marks carry over). + +**C. Agent pipeline.** [document.ts](../../agents/document.ts): blocks/anchors/read/insert/replace/suggest/comment on the new serializer; performance engine rework as decided above; `exportMarkdown` and `/:id.md` through the serializer; `validateNewDocumentMarkdown` updated for what the schema accepts. Tool descriptions in [mcp-tools.ts](../../agents/mcp-tools.ts) updated (plain-text `find`, markdown block content). + +**D. UI reconciliation.** Mode menu: Preview → Markdown (source view component reusing `.preview`-era styling for `
`); CleanViewToggle removed; thread/comment click-targets and the scroll-to-highlight behavior re-verified over rich nodes; onboarding template re-authored rich.
+
+Each phase merges independently behind a green suite; the doc format flips when B lands, so A+B ship in one PR, C immediately after (agents mis-anchor against rich docs until C — acceptable only within one deploy window; prefer shipping A+B+C together to production).
+
+## Regression surfaces (the reason this plan is XL)
+
+- **Comment threads**: `threadIdForComment` hashes comment text — unaffected — but mark scanning (`scanDocumentComments`) walks the doc; re-verify over nested nodes and the Start-Editing timer fix.
+- **Suggest mode**: intercepting edits inside lists/headings; accept/reject across block boundaries (`processAllRanges` walks the whole doc — retest).
+- **Awareness cursors** inside nested nodes (collaboration-caret handles this; verify labels).
+- **blockHash drift**: any serializer nondeterminism (list tightness, escaping) breaks agent anchors between clients — the round-trip property tests are the gate.
+- **Rate limits** count mutated chars — unchanged semantics, but recount against serialized length.
+
+## Out of scope
+
+Toolbar buttons (plan 4), tables/task-list UI, attachments, version history (roadmap).

From 0b7a2fc52f382c132fe5bda0280a11c6711854a9 Mon Sep 17 00:00:00 2001
From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com>
Date: Mon, 31 Aug 2026 08:29:19 -0700
Subject: [PATCH 66/83] Fold data-model evaluation into the WYSIWYG plan

Block ids replace content-hash anchors (hashes demote to staleness
checks), the schema is constrained to markdown-complete forms (underline
dropped), the serializer must be deterministic, and a cold-store
projection section fixes the future database as derived, never
authoritative.

Co-Authored-By: Claude Fable 5 
---
 .../2026-08-31-formatting-toolbar-plan.md     |  6 ++--
 .../plans/2026-08-31-notes-import-overview.md |  3 +-
 docs/plans/2026-08-31-wysiwyg-editing-plan.md | 33 +++++++++++++++----
 3 files changed, 31 insertions(+), 11 deletions(-)

diff --git a/docs/plans/2026-08-31-formatting-toolbar-plan.md b/docs/plans/2026-08-31-formatting-toolbar-plan.md
index f88ff00f..b1646367 100644
--- a/docs/plans/2026-08-31-formatting-toolbar-plan.md
+++ b/docs/plans/2026-08-31-formatting-toolbar-plan.md
@@ -8,7 +8,7 @@
 
 From `notes/src/components/NoteEditor.tsx` `MenuBar`:
 
-- **Format menu** (trigger icon `format_size`): a horizontal B / I / U / S icon-button row at the top of the menu (active state = `bg-accent`), then items Body Text, Heading 1–3, Code — each with icon + active indication.
+- **Format menu** (trigger icon `format_size`): a horizontal B / I / S icon-button row at the top of the menu (active state = `bg-accent`), then items Body Text, Heading 1–3, Code — each with icon + active indication. (The source also has Underline; vapor drops it — plan 3's markdown-completeness rule.)
 - **Lists menu** (`format_list_bulleted`): Bullet List, Numbered List, Checkbox List, Quote.
 - **Insert menu** (`add_box`): Link to… (dialog), Divider, Code Block (toggles selection or inserts empty fence), Table (3×3 with header, disabled inside a table). Attach File and Sidekick block are roadmap features — the menu ships without them and gains items as those land.
 - **Table menu**: rendered only when `editor.isActive("table")` — add/delete column, add/delete row, delete table.
@@ -21,7 +21,7 @@ From `notes/src/components/NoteEditor.tsx` `MenuBar`:
 - **Link dialog, vapor-flavored.** The source dialog searches the user's other notes; vapor has no cross-document index, so v1 is URL + optional title over the current selection (insert-or-wrap logic imported as-is). An inter-document search belongs to the roadmap's document-linking item.
 - **Edit vs. suggest aware.** Formatting commands run through the same suggest-mode interception as typing: in suggest mode, toggling bold over a range produces a tracked change, not a silent mutation. This falls out of plan 3's suggest plugin if command transactions route through it — verify explicitly; it is the one behavior with no source-app precedent.
 - **Task/checkbox item ships disabled** until the task-list UI (roadmap) lands, or is omitted from v1 — decide at implementation by whether plan 3 enabled the nodes with usable defaults.
-- **Icons**: extend the Material Symbols subset in [root.tsx](../../app/root.tsx) with `format_size, format_bold, format_italic, format_underlined, strikethrough_s, format_paragraph, format_h1, format_h2, format_h3, format_list_bulleted, format_list_numbered, check_box, format_quote, add_box, link, horizontal_rule, code, table, table_rows, view_column, add` (keep sorted).
+- **Icons**: extend the Material Symbols subset in [root.tsx](../../app/root.tsx) with `format_size, format_bold, format_italic, strikethrough_s, format_paragraph, format_h1, format_h2, format_h3, format_list_bulleted, format_list_numbered, check_box, format_quote, add_box, link, horizontal_rule, code, table, table_rows, view_column, add` (keep sorted).
 
 ## Tasks
 
@@ -29,7 +29,7 @@ From `notes/src/components/NoteEditor.tsx` `MenuBar`:
 2. Focus/hover fade state (lift `isFocused` tracking from Editor into context or use `editor.isFocused`).
 3. Link dialog component (kit `Input` + `Button`; imported insert-or-wrap selection logic).
 4. Header wiring in [doc.$id.tsx](../../app/routes/doc.$id.tsx); mobile check that the scrolling header stays usable with the added group.
-5. Keyboard shortcuts that pair with the toolbar (⌘B/⌘I already via StarterKit; add ⌘U underline, ⌘⇧X strike if missing).
+5. Keyboard shortcuts that pair with the toolbar (⌘B/⌘I already via StarterKit; add ⌘⇧X strike if missing; no ⌘U — underline is out per plan 3).
 6. Suggest-mode formatting verification (tracked-change toggling) with tests.
 7. Tests: menu contents render; commands dispatch (mock editor per existing patterns); active states reflect `isActive`.
 
diff --git a/docs/plans/2026-08-31-notes-import-overview.md b/docs/plans/2026-08-31-notes-import-overview.md
index 8529fcf5..f8e724aa 100644
--- a/docs/plans/2026-08-31-notes-import-overview.md
+++ b/docs/plans/2026-08-31-notes-import-overview.md
@@ -25,7 +25,8 @@ Recommended order: **1 → 2 → 3 → 4**, then roadmap items from 5 as separat
 ## Ground rules for all plans in this series
 
 - **vapor's collaboration model wins.** The source app is single-user with debounced saves; vapor is Yjs-CRDT multiplayer with agents as peers. Anything in the source built around save/sync (debounce, conflict states, background refetch) is *not* imported — Yjs already solves it.
-- **Markdown stays the interchange format.** `/:id.md`, agent RPCs, and exports keep speaking markdown regardless of how the editor renders.
+- **Markdown stays the interchange format.** `/:id.md`, agent RPCs, and exports keep speaking markdown regardless of how the editor renders. The schema stays *markdown-complete* — every node and mark has a canonical GFM + CriticMarkup form (which is why underline doesn't make the trip).
+- **Blocks are addressed by persistent id, verified by hash.** Plan 3 introduces immutable block ids for agent addressing (content hashes demote to staleness checks) — the same ids that make a future database cold store's `blocks` projection possible. The DO + Yjs pair remains the live source of truth; any future database is a derived projection, never a write path.
 - **Existing documents may break.** Documents expire after 99 hours, so there is no migration burden worth engineering for; a schema change that renders pre-change docs oddly for their remaining lifetime is acceptable (same call as the `vpr_` token retirement).
 - **Suggest mode and comments must survive every step.** CriticMarkup marks, thread anchoring, and the agent tool surface are vapor's differentiators; each plan lists them as explicit regression surfaces.
 - Each plan below is written to be executed via `superpowers:writing-plans` → implementation when picked up; the documents here fix scope, decisions, and sequencing, not step-by-step TDD scripts.
diff --git a/docs/plans/2026-08-31-wysiwyg-editing-plan.md b/docs/plans/2026-08-31-wysiwyg-editing-plan.md
index 3a071114..7501751f 100644
--- a/docs/plans/2026-08-31-wysiwyg-editing-plan.md
+++ b/docs/plans/2026-08-31-wysiwyg-editing-plan.md
@@ -12,23 +12,41 @@ The notes app: TipTap StarterKit nodes edited rich; markdown only at the boundar
 
 The import is therefore **a document-model change**, not a rendering toggle: the shared Yjs fragment starts holding real heading/list/quote/code nodes, and every consumer of "block text" moves to a serializer.
 
+## The data model, evaluated
+
+Two candidate live models were considered. *Markdown text per block in the CRDT* (closer to today) keeps stored bytes agent-native, but WYSIWYG over it requires mapping rich edits back to syntax edits — concurrent restyling of the same sentence produces interleaved `**` fragments, because the CRDT merges characters, not markdown grammar. It only works when one writer holds the document at a time, which is the opposite of vapor. *Rich ProseMirror nodes in the CRDT* merges concurrent edits at character level even inside formatting, and keeps suggestions/comments as CRDT-positioned marks that survive simultaneous human and agent edits. Rich-in-CRDT wins; markdown remains the interchange dialect at every boundary (agents, exports, raw endpoints, cold store).
+
 ## Decisions
 
-- **The CRDT holds rich nodes.** Schema: StarterKit (heading 1–3, bullet/ordered lists, blockquote, codeBlock, horizontalRule, hardBreak) + vapor's critic marks + collaboration/caret. Tables, task lists, underline are *enabled in the schema* from day one (so the doc format doesn't change again) but get UI only in plan 4 / roadmap.
-- **One serialization module, shared client/server.** New `app/shared/rich-markdown.ts` built on `prosemirror-model` + `prosemirror-markdown` (pure JS — runs in Workers): a schema instance, a `MarkdownParser` and `MarkdownSerializer` extended with CriticMarkup delimiters for the four critic marks. Converts via `y-prosemirror` helpers (`yXmlFragmentToProseMirrorRootNode`, `prosemirrorToYXmlFragment`). This **replaces `y-markdown.ts`** and the import/export halves of critic-parser/serializer (the parser stays for `/new` ingestion of critic syntax).
-- **Anchors hash the block's markdown.** `blockHash` is computed over each top-level node's markdown serialization — content-derived, identical on every client, and stable for unchanged blocks. `read_document` returns markdown per block. **`suggest.find` matches plain text** (`node.textContent`), because agents quote what they read and offsets must map to document positions; tool descriptions updated to say so.
+- **The CRDT holds rich nodes.** Schema: StarterKit (heading 1–3, bullet/ordered lists, blockquote, codeBlock, horizontalRule, hardBreak) + vapor's critic marks + collaboration/caret. Tables and task lists are *enabled in the schema* from day one (so the doc format doesn't change again) but get UI only in plan 4 / roadmap.
+- **The schema stays markdown-complete.** Every node and mark must have a canonical GFM + CriticMarkup form, so markdown round-trips losslessly and the derived layers below stay truthful. Consequence: **underline is dropped** from the import (no markdown syntax; the `` inline-HTML passthrough alternative was considered and rejected as a leak into every agent read). Plan 4's toolbar ships B/I/S without U.
+- **Blocks get persistent IDs; hashes demote to staleness checks.** Each top-level block carries an immutable short id as a node attribute, assigned at creation by a small ProseMirror plugin and synced through Yjs like any attribute. Agent addressing changes accordingly:
+  - `read_document` returns `{id, hash, markdown}` per block; `insert`/`suggest`/`comment` target the **block id**, which survives edits and moves — today's content-hash anchors go stale on any edit and silently race concurrent typing.
+  - Mutating tools also send the last-seen `hash`; on mismatch the DocumentAgent rejects with a `stale_block` error carrying the current block, so agents re-read instead of mis-anchoring. Better failure mode than drift.
+  - `await_events` gains block-level change events ("block b7 changed"), enabling incremental agent loops instead of full re-reads.
+- **One serialization module, shared client/server.** New `app/shared/rich-markdown.ts` built on `prosemirror-model` + `prosemirror-markdown` (pure JS — runs in Workers): a schema instance, a `MarkdownParser` and `MarkdownSerializer` extended with CriticMarkup delimiters for the four critic marks. Converts via `y-prosemirror` helpers (`yXmlFragmentToProseMirrorRootNode`, `prosemirrorToYXmlFragment`). This **replaces `y-markdown.ts`** and the import/export halves of critic-parser/serializer (the parser stays for `/new` ingestion of critic syntax). The serializer must be deterministic (normalized list markers, escaping, tightness) — hashes and future cold-store diffs depend on it; round-trip property tests are the gate.
+- **`suggest.find` matches plain text** (`node.textContent`), because agents quote what they read and offsets must map to document positions; tool descriptions updated to say so.
 - **No literal critic delimiters in the doc.** Suggestions and comments exist purely as marks; `{++…++}` appears only in exports and raw endpoints. Consequences: `markdown-decorations.ts` and the `cm-delimiter` widgets are deleted; **clean view is retired** (there is no markup to hide — `CleanViewToggle` goes away).
 - **Preview becomes Source.** WYSIWYG makes the rendered preview redundant. The mode menu's Preview item becomes **Markdown** — a read-only view of the serialized markdown (the inverse of today). `P`-hold keeps working, showing source.
 - **Typing performance engine goes block-structured.** Agent inserts parse markdown → nodes; the engine appends each block element, then types its text run-by-run *with formatting attributes* (`Y.XmlText.insert(idx, text, attrs)`), so styled text styles while typing. Multi-block inserts animate block-by-block — this also delivers the previously approved fix for multi-paragraph inserts skipping animation, and pace retunes to ~40–70 WPM in the same change.
 - **Old documents are not migrated.** A pre-change doc opens as flat paragraphs of literal markdown text in the new schema (valid, just unstyled) and expires within 99 hours. The onboarding template is re-imported through the new parser at creation, so new docs are born rich.
 
+## Cold store projection (designed now, built later)
+
+A future database layer stores **two representations, both derived from the live DO**:
+
+1. **Yjs snapshot blob** — opaque binary, the only representation that can rehydrate a live collaborative session with full mark/position fidelity.
+2. **A `blocks` projection** — `(doc_id, block_id, position, markdown, hash, updated_at)` plus the existing threads data. Queryable, human-readable, durable against schema evolution (markdown doesn't rot the way ProseMirror JSON does when node specs change). Block IDs are what make this table possible — content-hash addressing gives a block no identity across time, so per-block history and diffs can't exist without them.
+
+Hard rule: the DO + Yjs pair stays the live source of truth; the database is a projection (and, if documents ever outlive 99 hours, an archive) — never a write path the CRDT syncs *from*. Nothing in this plan builds the store; the block-ID and determinism decisions above are what keep it cheap to add.
+
 ## Phases
 
-**A. Serialization core (server-safe, test-heavy).** `rich-markdown.ts` with round-trip property tests: markdown → nodes → markdown stable for the whole feature matrix (headings, nested lists, quotes, fenced code with language, hr, inline marks, links, critic syntax, mixed nesting). Port `getBlocks`/`yDocToMarkdown`/`buildMarkdownBlocks`/insert helpers onto it. Delete `y-markdown.ts`.
+**A. Serialization core (server-safe, test-heavy).** `rich-markdown.ts` with round-trip property tests: markdown → nodes → markdown stable for the whole feature matrix (headings, nested lists, quotes, fenced code with language, hr, inline marks, links, critic syntax, mixed nesting). Schema includes the block-id attribute. Port `getBlocks`/`yDocToMarkdown`/`buildMarkdownBlocks`/insert helpers onto it. Delete `y-markdown.ts`.
 
-**B. Client editor.** Enable StarterKit nodes in [useYjsEditor.ts](../../app/lib/useYjsEditor.ts) / Editor extensions; remove markdown-decorations and delimiter CSS; wire markdown paste (clipboard markdown → parsed nodes) and `/new` body ingestion through the parser; input rules (`#`, `-`, `1.`, `` ``` ``, `>`) + Typography. Suggest-mode plugin re-verified over rich nodes (marks apply across node boundaries — the existing `inclusive: false` marks carry over).
+**B. Client editor.** Enable StarterKit nodes in [useYjsEditor.ts](../../app/lib/useYjsEditor.ts) / Editor extensions; the block-id plugin (assign missing ids on creation; on block split, the block containing the original start keeps the id and the remainder gets a fresh one); remove markdown-decorations and delimiter CSS; wire markdown paste (clipboard markdown → parsed nodes) and `/new` body ingestion through the parser; input rules (`#`, `-`, `1.`, `` ``` ``, `>`) + Typography. Suggest-mode plugin re-verified over rich nodes (marks apply across node boundaries — the existing `inclusive: false` marks carry over).
 
-**C. Agent pipeline.** [document.ts](../../agents/document.ts): blocks/anchors/read/insert/replace/suggest/comment on the new serializer; performance engine rework as decided above; `exportMarkdown` and `/:id.md` through the serializer; `validateNewDocumentMarkdown` updated for what the schema accepts. Tool descriptions in [mcp-tools.ts](../../agents/mcp-tools.ts) updated (plain-text `find`, markdown block content).
+**C. Agent pipeline and protocol.** [document.ts](../../agents/document.ts): blocks/read/insert/replace/suggest/comment addressed by block id with hash staleness checks (`stale_block` added to `AgentErrorCode`, response carries the current block); block-level change events in `await_events`; agent-side inserts assign ids server-side; performance engine rework as decided above; `exportMarkdown` and `/:id.md` through the serializer; `validateNewDocumentMarkdown` updated for what the schema accepts. Tool schemas and descriptions in [mcp-tools.ts](../../agents/mcp-tools.ts) updated (block ids, `expected_hash`, plain-text `find`, markdown block content).
 
 **D. UI reconciliation.** Mode menu: Preview → Markdown (source view component reusing `.preview`-era styling for `
`); CleanViewToggle removed; thread/comment click-targets and the scroll-to-highlight behavior re-verified over rich nodes; onboarding template re-authored rich.
 
@@ -39,7 +57,8 @@ Each phase merges independently behind a green suite; the doc format flips when
 - **Comment threads**: `threadIdForComment` hashes comment text — unaffected — but mark scanning (`scanDocumentComments`) walks the doc; re-verify over nested nodes and the Start-Editing timer fix.
 - **Suggest mode**: intercepting edits inside lists/headings; accept/reject across block boundaries (`processAllRanges` walks the whole doc — retest).
 - **Awareness cursors** inside nested nodes (collaboration-caret handles this; verify labels).
-- **blockHash drift**: any serializer nondeterminism (list tightness, escaping) breaks agent anchors between clients — the round-trip property tests are the gate.
+- **Serializer determinism**: any nondeterminism (list tightness, escaping) makes staleness hashes disagree between clients — the round-trip property tests are the gate.
+- **Block-id integrity**: ids must survive splits/joins per the plugin policy and never duplicate (paste of copied blocks must re-mint ids); duplicated ids silently misroute agent edits.
 - **Rate limits** count mutated chars — unchanged semantics, but recount against serialized length.
 
 ## Out of scope

From aaf3907d4a974602cb4773e9113df6022c648f8e Mon Sep 17 00:00:00 2001
From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com>
Date: Mon, 31 Aug 2026 08:39:17 -0700
Subject: [PATCH 67/83] Add Base UI component kit; migrate menus off Radix

Button/Menu/Input/Toolbar wrappers over @base-ui/react plus cn();
ModeMenu, ShareButton, and ThemeSelector move to the kit Menu; Radix
dropdown-menu and switch dependencies removed. Semantic accent,
destructive, and primary tokens with dark/auto overrides; squircle
utility.

Co-Authored-By: Claude Fable 5 
---
 app/app.css                      |  23 +
 app/components/ModeMenu.tsx      | 117 ++---
 app/components/ShareButton.tsx   |  34 +-
 app/components/ThemeSelector.tsx |  99 +---
 app/components/ui/button.tsx     |  36 ++
 app/components/ui/input.tsx      |  23 +
 app/components/ui/menu.tsx       |  92 ++++
 app/components/ui/toolbar.tsx    |  26 +
 app/lib/cn.ts                    |   6 +
 package-lock.json                | 849 ++++---------------------------
 package.json                     |   5 +-
 11 files changed, 408 insertions(+), 902 deletions(-)
 create mode 100644 app/components/ui/button.tsx
 create mode 100644 app/components/ui/input.tsx
 create mode 100644 app/components/ui/menu.tsx
 create mode 100644 app/components/ui/toolbar.tsx
 create mode 100644 app/lib/cn.ts

diff --git a/app/app.css b/app/app.css
index be4335c5..dfa4bef1 100644
--- a/app/app.css
+++ b/app/app.css
@@ -13,6 +13,25 @@
   --color-coral: #e8564a;
   --color-chartreuse: #b5e636;
   --color-canary: #ffe014;
+  --color-accent: #ececec;
+  --color-destructive: #ef4444;
+}
+
+/* Semantic aliases resolve through the paper/ink pair so theme overrides
+   (which redefine paper/ink) carry them automatically. */
+@theme inline {
+  --color-primary: var(--color-ink);
+}
+
+@utility squircle-* {
+  --squircle-radius: --value(--radius-*, [length], [percentage], [*]);
+
+  border-radius: var(--squircle-radius);
+
+  @supports (corner-shape: squircle) {
+    corner-shape: squircle;
+    border-radius: calc(var(--squircle-radius) * 2);
+  }
 }
 
 html {
@@ -404,6 +423,8 @@ body {
   --color-paper: #111111;
   --color-muted: #777;
   --color-border: #2a2a2a;
+  --color-accent: #262626;
+  --color-destructive: #f87171;
   color-scheme: dark;
 }
 
@@ -425,6 +446,8 @@ body {
     --color-paper: #111111;
     --color-muted: #777;
     --color-border: #2a2a2a;
+    --color-accent: #262626;
+    --color-destructive: #f87171;
     color-scheme: dark;
   }
 
diff --git a/app/components/ModeMenu.tsx b/app/components/ModeMenu.tsx
index 9f9ec7c4..e511f583 100644
--- a/app/components/ModeMenu.tsx
+++ b/app/components/ModeMenu.tsx
@@ -1,7 +1,7 @@
 import { useEffect, useState } from "react";
-import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
 import { useDocument } from "~/lib/DocumentContext";
 import { hasSuggestionMarkup, processAllRanges } from "~/lib/suggestion-actions";
+import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "~/components/ui/menu";
 import Icon from "~/components/Icon";
 
 function ChevronDown() {
@@ -13,9 +13,9 @@ function ChevronDown() {
 }
 
 /**
- * Header menu for the editing mode. Edit and Suggest switch modes, Preview
- * toggles the rendered view; Accept all / Reject all apply to every
- * pending suggestion.
+ * Header menu for the editing mode. Edit and Suggest switch modes, Markdown
+ * toggles the source view; Accept all / Reject all apply to every pending
+ * suggestion.
  */
 export default function ModeMenu() {
   const { editorInstance: editor, mode, setMode, showPreview, togglePreview } = useDocument();
@@ -31,72 +31,65 @@ export default function ModeMenu() {
     };
   }, [editor]);
 
-  const itemClass =
-    "flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-border data-[disabled]:cursor-default data-[disabled]:text-muted/40";
+  const itemClass = "gap-2";
 
   return (
-    
-      
+    
+      
         
-      
-      
-        
+      
+         {
+            setMode("edit");
+            if (showPreview) togglePreview();
+          }}
         >
-           {
-              setMode("edit");
-              if (showPreview) togglePreview();
-            }}
-            className={itemClass}
-          >
-            
-            Edit
-            {mode === "edit" && !showPreview && {"✓"}}
-          
-           {
-              setMode("suggest");
-              if (showPreview) togglePreview();
-            }}
-            className={itemClass}
-          >
-            
-            Suggest
-            {mode === "suggest" && !showPreview && {"✓"}}
-          
-          
-            
-            Preview
-            {showPreview && {"✓"}}
-          
-          
-           editor && processAllRanges(editor, true)}
-            className={itemClass}
-          >
-            
-            Accept all
-          
-           editor && processAllRanges(editor, false)}
-            className={itemClass}
-          >
-            
-            Reject all
-          
-        
-      
-    
+          
+          Edit
+          {mode === "edit" && !showPreview && {"✓"}}
+        
+         {
+            setMode("suggest");
+            if (showPreview) togglePreview();
+          }}
+        >
+          
+          Suggest
+          {mode === "suggest" && !showPreview && {"✓"}}
+        
+        
+          
+          Markdown
+          {showPreview && {"✓"}}
+        
+        
+         editor && processAllRanges(editor, true)}
+        >
+          
+          Accept all
+        
+         editor && processAllRanges(editor, false)}
+        >
+          
+          Reject all
+        
+      
+    
   );
 }
diff --git a/app/components/ShareButton.tsx b/app/components/ShareButton.tsx
index 86c25247..40c81220 100644
--- a/app/components/ShareButton.tsx
+++ b/app/components/ShareButton.tsx
@@ -1,7 +1,7 @@
 import { useState, useCallback } from "react";
-import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
 import { serializeThreads } from "~/lib/thread-serialization";
 import { useDocument } from "~/lib/DocumentContext";
+import { Menu, MenuTrigger, MenuContent, MenuItem } from "~/components/ui/menu";
 
 export default function ShareButton() {
   const { docId, markdown, threads } = useDocument();
@@ -25,8 +25,8 @@ export default function ShareButton() {
   }, [docId, markdown, threads]);
 
   return (
-    
-      
+    
+      
         
-      
-      
-        
-          
-            {copied ? "\u2713 Copied" : "Copy link"}
-          
-          
-            Download
-          
-        
-      
-    
+      
+      
+        {copied ? "✓ Copied" : "Copy link"}
+        Download
+      
+    
   );
 }
diff --git a/app/components/ThemeSelector.tsx b/app/components/ThemeSelector.tsx
index c5c0cb87..2ed8a624 100644
--- a/app/components/ThemeSelector.tsx
+++ b/app/components/ThemeSelector.tsx
@@ -1,51 +1,13 @@
 import { useState, useEffect } from "react";
-import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
 import { useTheme, type Theme } from "~/lib/useTheme";
+import { Menu, MenuTrigger, MenuContent, MenuItem } from "~/components/ui/menu";
+import Icon from "~/components/Icon";
 
-function SunIcon() {
-  return (
-    
-      
-      
-      
-      
-      
-      
-      
-      
-      
-    
-  );
-}
-
-function MoonIcon() {
-  return (
-    
-      
-    
-  );
-}
-
-function AutoIcon() {
-  return (
-    
-      
-      
-    
-  );
-}
-
-const icons: Record React.JSX.Element> = {
-  light: SunIcon,
-  dark: MoonIcon,
-  auto: AutoIcon,
-};
-
-const labels: Record = {
-  light: "Light",
-  dark: "Dark",
-  auto: "Auto",
-};
+const options: { value: Theme; icon: string; label: string }[] = [
+  { value: "light", icon: "light_mode", label: "Light" },
+  { value: "dark", icon: "dark_mode", label: "Dark" },
+  { value: "auto", icon: "brightness_auto", label: "Auto" },
+];
 
 function ChevronDown() {
   return (
@@ -61,54 +23,41 @@ export default function ThemeSelector() {
   // eslint-disable-next-line react-hooks/set-state-in-effect
   useEffect(() => setMounted(true), []);
 
-  const Icon = icons[theme];
+  const current = options.find((o) => o.value === theme) ?? options[2];
 
-  // Render a static placeholder during SSR to avoid Radix useId hydration mismatch
+  // Render a static placeholder during SSR to avoid portal/id hydration mismatch
   if (!mounted) {
     return (
       
     );
   }
 
   return (
-    
-      
+    
+      
         
-      
-      
-        
-          {(["light", "dark", "auto"] as Theme[]).map((t) => {
-            const ItemIcon = icons[t];
-            return (
-               setTheme(t)}
-                className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-border"
-              >
-                
-                {labels[t]}
-                {theme === t && {"\u2713"}}
-              
-            );
-          })}
-        
-      
-    
+      
+      
+        {options.map((o) => (
+           setTheme(o.value)}>
+            
+            {o.label}
+            {theme === o.value && {"✓"}}
+          
+        ))}
+      
+    
   );
 }
diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx
new file mode 100644
index 00000000..c2e115b2
--- /dev/null
+++ b/app/components/ui/button.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cn } from "~/lib/cn";
+
+interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+  variant?: "default" | "ghost" | "destructive";
+  size?: "default" | "sm" | "icon";
+}
+
+export const Button = React.forwardRef(
+  ({ className, variant = "default", size = "default", type = "button", ...props }, ref) => {
+    return (
+      
+  );
+
+  const blockItem = (
+    icon: string,
+    label: string,
+    active: boolean,
+    run: () => void,
+  ) => (
+    
+      
+      {label}
+      {active && {"✓"}}
+    
+  );
+
+  const insertLink = () => {
+    const href = linkUrl.trim();
+    if (!href) return;
+    const { from, to } = editor.state.selection;
+    if (from !== to) {
+      editor.chain().focus().setLink({ href }).run();
+    } else {
+      const label = linkTitle.trim() || href;
+      editor
+        .chain()
+        .focus()
+        .insertContent({ type: "text", text: label, marks: [{ type: "link", attrs: { href } }] })
+        .run();
+    }
+    setShowLinkDialog(false);
+    setLinkUrl("");
+    setLinkTitle("");
+  };
+
+  return (
+    
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + + +
+ {markButton("bold", "format_bold", "Bold", () => editor.chain().focus().toggleBold().run())} + {markButton("italic", "format_italic", "Italic", () => editor.chain().focus().toggleItalic().run())} + {markButton("strike", "strikethrough_s", "Strikethrough", () => editor.chain().focus().toggleStrike().run())} + {markButton("code", "code", "Inline code", () => editor.chain().focus().toggleCode().run())} +
+ + {blockItem("format_paragraph", "Body text", editor.isActive("paragraph"), () => + editor.chain().focus().setParagraph().run())} + {blockItem("format_h1", "Heading 1", editor.isActive("heading", { level: 1 }), () => + editor.chain().focus().toggleHeading({ level: 1 }).run())} + {blockItem("format_h2", "Heading 2", editor.isActive("heading", { level: 2 }), () => + editor.chain().focus().toggleHeading({ level: 2 }).run())} + {blockItem("format_h3", "Heading 3", editor.isActive("heading", { level: 3 }), () => + editor.chain().focus().toggleHeading({ level: 3 }).run())} +
+
+ + + + + + + {blockItem("format_list_bulleted", "Bullet list", editor.isActive("bulletList"), () => + editor.chain().focus().toggleBulletList().run())} + {blockItem("format_list_numbered", "Numbered list", editor.isActive("orderedList"), () => + editor.chain().focus().toggleOrderedList().run())} + {blockItem("format_quote", "Quote", editor.isActive("blockquote"), () => + editor.chain().focus().toggleBlockquote().run())} + + + + + + + + + { + setLinkUrl(""); + setLinkTitle(""); + setShowLinkDialog(true); + }} + > + + Link… + + editor.chain().focus().setHorizontalRule().run()}> + + Divider + + editor.chain().focus().toggleCodeBlock().run()}> + + Code block + + + + + {showLinkDialog && ( + <> +
setShowLinkDialog(false)} /> +
{ + if (e.key === "Escape") setShowLinkDialog(false); + if (e.key === "Enter") insertLink(); + }} + > +
+ setLinkUrl(e.target.value)} + placeholder="https://…" + /> + {editor.state.selection.empty && ( + setLinkTitle(e.target.value)} + placeholder="Link text (optional)" + /> + )} + +
+
+ + )} +
+ ); +} diff --git a/app/root.tsx b/app/root.tsx index c53ecc79..04c0ddd7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -37,7 +37,7 @@ export const links: Route.LinksFunction = () => [ // Subset to the icon names actually used — keep this list sorted and in // sync with usages or new glyphs render as raw text. rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=account_circle,brightness_auto,check,dark_mode,delete,done_all,edit,light_mode,logout,more_vert,rate_review,remove_done,smart_toy,undo,visibility&display=block", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=account_circle,add_box,brightness_auto,check,code,dark_mode,delete,done_all,edit,format_bold,format_h1,format_h2,format_h3,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,horizontal_rule,light_mode,link,logout,more_vert,rate_review,remove_done,smart_toy,strikethrough_s,undo,visibility&display=block", }, ]; diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index 2b82bbd2..d71d5e99 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -12,6 +12,7 @@ import Preview from "~/components/Preview"; import ShareButton from "~/components/ShareButton"; import AgentsPanel from "~/components/AgentsPanel"; import ModeMenu from "~/components/ModeMenu"; +import FormatToolbar from "~/components/FormatToolbar"; import HeaderMenu from "~/components/HeaderMenu"; import CommentInput from "~/components/CommentInput"; import ThreadList from "~/components/ThreadList"; @@ -101,6 +102,9 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul )}
+
+ +
diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 104a5671..f10c6794 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -120,10 +120,8 @@ export default function Home({ loaderData }: Route.ComponentProps) { />

Or from your terminal

- - ` - {curlCommand} - ` + + {curlCommand}
+
+ +
diff --git a/tests/helpers/document-context.tsx b/tests/helpers/document-context.tsx index 9c39fbee..777b8f3a 100644 --- a/tests/helpers/document-context.tsx +++ b/tests/helpers/document-context.tsx @@ -42,6 +42,7 @@ export function createMockDocumentContext( awareness: {} as DocumentContextValue["yjs"]["awareness"], socket: null as DocumentContextValue["yjs"]["socket"], synced: true, + asleep: false, user: { name: "Test User", color: "#000", colorLight: "#ccc" }, mode: "edit" as const, setMode: vi.fn(), diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index 5eb3dd71..ae9ba869 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -650,6 +650,9 @@ describe("DocumentAgent", () => { const a = connectYjsClient(); a.doc.getText("default").insert(0, "persisted data"); cleanup(a); + // Persistence is debounced (1s quiet edge); production flushes when + // the last connection closes — invoke that flush directly here. + (agent as unknown as { flushDocState: () => void }).flushDocState(); mockConnectionMap.clear(); // Simulate DO restart: new agent instance, same SQL store @@ -1869,7 +1872,7 @@ describe("DocumentAgent", () => { await vi.advanceTimersByTimeAsync(50); const result = await promise; - expect(result).toEqual({ events: [], cursor: 0 }); + expect(result).toEqual({ events: [], cursor: 0, retryAfterMs: 30_000 }); }); it("excludes already-seen events once the cursor advances past them", async () => { @@ -1890,7 +1893,7 @@ describe("DocumentAgent", () => { await vi.advanceTimersByTimeAsync(50); const second = await secondPromise; - expect(second).toEqual({ events: [], cursor }); + expect(second).toEqual({ events: [], cursor, retryAfterMs: 30_000 }); cleanup(client); }); diff --git a/tests/unit/components/connection-status.test.tsx b/tests/unit/components/connection-status.test.tsx new file mode 100644 index 00000000..187df4c5 --- /dev/null +++ b/tests/unit/components/connection-status.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { createElement } from "react"; +import { act } from "@testing-library/react"; +import { renderWithDocument, createMockDocumentContext } from "../../helpers/document-context"; +import ConnectionStatus from "~/components/ConnectionStatus"; + +function yjsWith(overrides: Record) { + const base = createMockDocumentContext().yjs; + return { ...base, ...overrides } as never; +} + +describe("ConnectionStatus", () => { + it("shows Connecting before any connection", () => { + const { getByText } = renderWithDocument(createElement(ConnectionStatus), { + context: { yjs: yjsWith({ socket: null }) }, + }); + expect(getByText("Connecting")).toBeTruthy(); + }); + + it("shows Sleeping when the tab is asleep", () => { + const { getByText } = renderWithDocument(createElement(ConnectionStatus), { + context: { yjs: yjsWith({ socket: null, asleep: true }) }, + }); + expect(getByText("Sleeping")).toBeTruthy(); + }); + + it("shows Offline when the browser loses the network", () => { + const { getByText } = renderWithDocument(createElement(ConnectionStatus), { + context: { yjs: yjsWith({ socket: null }) }, + }); + act(() => { + window.dispatchEvent(new Event("offline")); + }); + expect(getByText("Offline")).toBeTruthy(); + act(() => { + window.dispatchEvent(new Event("online")); + }); + expect(getByText("Connecting")).toBeTruthy(); + }); + + it("shows Connected on an open socket and Reconnecting after it drops", () => { + const listeners = new Map void>>(); + const socket = { + readyState: WebSocket.OPEN, + addEventListener(type: string, fn: () => void) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(fn); + }, + removeEventListener(type: string, fn: () => void) { + listeners.get(type)?.delete(fn); + }, + }; + const { getByText } = renderWithDocument(createElement(ConnectionStatus), { + context: { yjs: yjsWith({ socket }) }, + }); + expect(getByText("Connected")).toBeTruthy(); + + act(() => { + socket.readyState = WebSocket.CLOSED; + for (const fn of listeners.get("close") ?? []) fn(); + }); + expect(getByText("Reconnecting")).toBeTruthy(); + }); +}); diff --git a/tests/unit/components/header-menu.test.tsx b/tests/unit/components/header-menu.test.tsx index c6fc5f21..cc744131 100644 --- a/tests/unit/components/header-menu.test.tsx +++ b/tests/unit/components/header-menu.test.tsx @@ -23,14 +23,13 @@ describe("HeaderMenu", () => { vi.restoreAllMocks(); }); - it("opens with status, Agents, and theme rows", async () => { + it("opens with Agents and theme rows", async () => { const onOpenAgents = vi.fn(); renderWithDocument(createElement(HeaderMenu, { onOpenAgents })); fireEvent.click(screen.getByLabelText("Menu")); expect(screen.getByText("Agents")).toBeTruthy(); expect(screen.getByText("Theme")).toBeTruthy(); - expect(screen.getByText("Connecting")).toBeTruthy(); expect(screen.getByLabelText("Light")).toBeTruthy(); expect(screen.getByLabelText("Dark")).toBeTruthy(); expect(screen.getByLabelText("Auto")).toBeTruthy(); diff --git a/tests/unit/lib/use-idle-sleep.test.ts b/tests/unit/lib/use-idle-sleep.test.ts new file mode 100644 index 00000000..c4930f77 --- /dev/null +++ b/tests/unit/lib/use-idle-sleep.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useIdleSleep, IDLE_SLEEP_MS, HIDDEN_SLEEP_MS } from "~/lib/useIdleSleep"; + +describe("useIdleSleep", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts awake and sleeps after the idle window", () => { + const { result } = renderHook(() => useIdleSleep()); + expect(result.current).toBe(false); + act(() => { + vi.advanceTimersByTime(IDLE_SLEEP_MS + 1); + }); + expect(result.current).toBe(true); + }); + + it("activity resets the idle timer and wakes a sleeping tab", () => { + const { result } = renderHook(() => useIdleSleep()); + act(() => { + vi.advanceTimersByTime(IDLE_SLEEP_MS + 1); + }); + expect(result.current).toBe(true); + + act(() => { + window.dispatchEvent(new Event("keydown")); + }); + expect(result.current).toBe(false); + + // Activity keeps re-arming: half the window, activity, half again — still awake + act(() => { + vi.advanceTimersByTime(IDLE_SLEEP_MS / 2); + window.dispatchEvent(new Event("pointermove")); + vi.advanceTimersByTime(IDLE_SLEEP_MS / 2); + }); + expect(result.current).toBe(false); + }); + + it("sleeps a minute after the page is hidden", () => { + const { result } = renderHook(() => useIdleSleep()); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "hidden", + }); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(HIDDEN_SLEEP_MS + 1); + }); + expect(result.current).toBe(true); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "visible", + }); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(result.current).toBe(false); + }); +}); From 4473d24fd360f76b8ccb0cc63d2ee30a261203fc Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:01:48 -0700 Subject: [PATCH 74/83] Plan the MCP Events polyfill Co-Authored-By: Claude Fable 5 --- .../2026-08-31-mcp-events-polyfill-plan.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/plans/2026-08-31-mcp-events-polyfill-plan.md diff --git a/docs/plans/2026-08-31-mcp-events-polyfill-plan.md b/docs/plans/2026-08-31-mcp-events-polyfill-plan.md new file mode 100644 index 00000000..bec35222 --- /dev/null +++ b/docs/plans/2026-08-31-mcp-events-polyfill-plan.md @@ -0,0 +1,64 @@ +# MCP Events polyfill + +**Goal:** Replace idle polling with standards-shaped push. vapor implements the MCP Triggers & Events Working Group's [Events design sketch](https://github.com/modelcontextprotocol/experimental-ext-triggers-events/pull/1) — optimistically, before it ratifies — so agents can register **webhooks** for mentions and document changes today, and so vapor becomes a running reference implementation of the draft. + +**Relationship to other plans:** completes the agent half of the [sleeping-tabs plan](2026-08-31-sleeping-tabs-plan.md) (the 15s-capped `await_events` + `retryAfterMs` was the stopgap; webhooks are the fix). Builds on the events table and cursor that shipped with the WYSIWYG work. + +## What we are polyfilling + +The sketch (draft by the WG's Anthropic co-lead, 2026-02-19) defines an `events` capability with: + +- **`events/list`** — event types: `{name, description, delivery: ("poll"|"push"|"webhook")[], inputSchema, payloadSchema}`. +- **`events/poll`** — `{name, arguments, cursor, maxEvents}` → `{events[], cursor, truncated, hasMore, nextPollMs}`. Stateless per request. +- **`events/subscribe`** (webhook only) — `{name, arguments, delivery: {mode: "webhook", url, secret}, cursor, ttlMs}` → `{id, refreshBefore, cursor, truncated}`. Idempotent upsert keyed on `(principal, url, name, arguments)`; refresh = re-subscribe; `events/unsubscribe` for eager teardown. +- **Delivery**: POST of an `EventOccurrence` `{eventId, name, timestamp, data, cursor}` signed per **Standard Webhooks** (`webhook-id` / `webhook-timestamp` / `webhook-signature: v1,base64(HMAC-SHA256(secret, "id.timestamp.body"))`) plus `X-MCP-Subscription-Id`. +- **Rules that bind us**: `delivery.secret` is client-supplied and must match `whsec_` + base64(24–64 bytes); webhook mode **requires an authenticated principal** (unauthenticated servers may offer poll/push only); cursors are opaque, client-owned, and `truncated: true` signals gaps; error codes `-32011 NotFound` … `-32015 CallbackEndpointError`. +- **`events/stream`** (push over a long-lived request) also exists in the sketch — **out of scope here** (it re-pins the DO; exactly what the sleeping-tabs work removed). + +## vapor's event catalog + +One events core, mapped from what the DocumentAgent already records: + +| Event type | Arguments (`inputSchema`) | Payload (`payloadSchema`) | +|---|---|---| +| `document.changed` | `{doc_id}` | `{doc_id, digest}` — the existing doc_changed digest | +| `mention` | `{doc_id}` | `{doc_id, agent, text}` | +| `thread.reply` | `{doc_id}` | `{doc_id, agent, thread_id, text}` | + +- **Cursor** = the existing per-doc `events.seq`, serialized opaquely as `s`. **`eventId`** = `:` (stable, dedupable). +- Subscriptions are **doc-scoped** in v1 (arguments require `doc_id`). An identity-wide inbox ("any doc I'm enrolled in", routed via the Registry) is the natural v2 and slots into the same catalog as argument-free variants. +- `mention` and `thread.reply` deliver only events addressed to the subscribing identity — same filtering `agentAwaitEvents` does today. + +## How the polyfill is provided — three layers over one core + +The core (event log + cursor + subscription store + dispatcher) is protocol-agnostic; the layers are skins. When the SEP ratifies with different names or shapes, only the skins get re-cut. + +**Layer 1 — spec-shaped protocol methods (for tomorrow's clients).** Mount `events/list`, `events/poll`, `events/subscribe`, `events/unsubscribe` as custom request handlers on VaporMcp's underlying `Server` (the low-level SDK accepts arbitrary method schemas), and declare `capabilities.events`. Shapes copied from the sketch verbatim, including its error codes. Tagged experimental via `_meta["fyi.vapor/events-draft"] = "2026-02-19"` so a future ratified version is distinguishable on the wire. No mainstream client calls these today; this layer exists so spec-native SDKs work against vapor on day one. + +**Layer 2 — tool mirrors (the polyfill for today's clients).** The same four operations exposed as ordinary tools — `events_list`, `events_poll`, `events_subscribe`, `events_unsubscribe` — with input schemas transliterated from the sketch. Any current MCP client can register a webhook via a tool call. Tool descriptions say plainly: this mirrors the draft MCP Events extension and will be deprecated in favor of the protocol methods when the SEP lands. `await_events` survives as a deprecated alias whose description points at `events_poll` (its response already matches the poll contract in spirit: events + cursor + retry pacing). + +**Layer 3 — the webhook dispatcher (the part that kills polling).** +- **Store**: a `subscriptions` table in the document's own DO (`id, principal, url, secret, name, arguments, cursor_floor, expires_at, failures, active`) — doc-scoped subscriptions live and die with the doc, which also gives TTL cleanup and the 99h expiry for free. +- **Auth**: per the sketch, webhook mode requires a principal — so `events_subscribe` works **only through the OAuth door** (`/mcp`); the anonymous door gets poll only, refused with `-32012 Forbidden`. This also keeps the public-doc abuse surface closed (no anonymous "make vapor POST to arbitrary URLs"). +- **Dispatch**: `recordEvent` → after the row insert, look up matching active subscriptions and POST each `EventOccurrence` with Standard Webhooks signatures via `waitUntil`. Coalescing: `document.changed` digests are already debounced server-side; mention/reply send immediately. +- **Retries & hygiene**: 2 retries with short backoff per delivery; `failures` increments on exhaustion and `active` flips false after 5 consecutive failures (the sketch's suspension semantics — a successful re-subscribe reactivates). HTTPS-only URLs; reject private-network literals (`localhost`, RFC1918, `.internal`) to keep the dispatcher from being an SSRF primitive. +- **TTL policy**: grant `min(suggested, 24h)` with a 5-minute floor; never grant no-expiry in v1 (the sketch lets servers refuse by granting finite). `refreshBefore` returned as ISO 8601; refresh is the idempotent re-subscribe the sketch specifies, including secret rotation semantics (replace; skip dual-signing in v1, documented). + +## Tasks + +1. **Core** (`agents/events.ts`, plain module, unit-testable): event-type catalog with zod schemas; cursor encode/decode; `EventOccurrence` construction; Standard Webhooks signing (WebCrypto HMAC — vapor already has the primitives in auth.server.ts); subscription-key hashing for `id`. +2. **DocumentAgent**: `subscriptions` table + `eventsSubscribe/eventsUnsubscribe/eventsPoll/eventsList` RPCs (verifyIdentity-gated, capability rules above); dispatcher wired into `recordEvent`; lazy TTL expiry on dispatch and on subscribe. +3. **Layer 2 tools** in mcp-tools.ts (schema transliteration; `await_events` deprecation note). +4. **Layer 1 methods** in agents/mcp.ts via `setRequestHandler` + `capabilities.events` declaration + `_meta` draft tag. +5. **Tests**: signing vectors against the Standard Webhooks spec examples; subscribe/refresh/expire lifecycle; dispatch retry/suspend; poll parity with `await_events`; anonymous-door refusal; SSRF guard. +6. **Docs**: `/mcp` help page gains an events section; a short note filed to the WG repo as field-report feedback once it's running (they're soliciting exactly this). + +## Drift management (this is a draft, and it will move) + +- The WG is actively debating whether webhooks belong at the protocol layer at all vs. a transport-level redelivery mechanism. If delivery moves to the transport, **layers 1–2 shrink but the core and dispatcher survive unchanged** — every variant still needs a cursored log, signed delivery, and subscription lifecycle. +- Watch items: the SEP ("Events in MCP v1") status in the incubation repo; SEP-1686 (Tasks) for interaction; rename churn. Re-cut the skins when ratified, keep tool mirrors one release past that for stragglers, then drop them. +- Everything user-visible carries the word *experimental* and the draft date, so nobody mistakes the polyfill for the standard. + +## Out of scope + +`events/stream` push mode; identity-wide (cross-document) subscriptions and the Registry inbox; dual-signature secret rotation; a standalone pager/hub product (see the webhook-infrastructure discussion — A2A `PushNotificationConfig`, Maritime, AgentMail all validate the space; vapor stays scoped to its own documents). From 76bcde71358070844db8aa374813daf8f126c451 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:11:02 -0700 Subject: [PATCH 75/83] Add DO usage guardrail script; mark plan phases shipped tools/do-usage.mjs reads DO active time and requests from the GraphQL Analytics API and reports each day against the free-tier budgets, warning at 70% and failing at 100%. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-31-sleeping-tabs-plan.md | 4 +- tools/do-usage.mjs | 116 ++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tools/do-usage.mjs diff --git a/docs/plans/2026-08-31-sleeping-tabs-plan.md b/docs/plans/2026-08-31-sleeping-tabs-plan.md index d1517e54..e2293178 100644 --- a/docs/plans/2026-08-31-sleeping-tabs-plan.md +++ b/docs/plans/2026-08-31-sleeping-tabs-plan.md @@ -2,6 +2,8 @@ **Goal:** An open vapor tab stops costing money when nobody is using it. Today every open tab — and every idling agent — pins its document's Durable Object in memory around the clock, which exhausted the free tier's daily duration quota on 2026-08-31 and took the whole product down for the day. +**Status:** Phases 1 and 2 shipped on the notes-import branch (PR #6, "Free-tier safeguards: sleeping tabs and DO wake hygiene") and are deployed. Phase 3's usage script lives at [tools/do-usage.mjs](../../tools/do-usage.mjs) — it needs a `CLOUDFLARE_API_TOKEN` with *Account Analytics: Read* (not yet provisioned; runs and fails cleanly without it). The webhook successor to `await_events` polling has its own plan: [MCP Events polyfill](2026-08-31-mcp-events-polyfill-plan.md). + ## Why documents never sleep The Agents SDK already serves connections through the WebSocket **hibernation API** (`hibernate: true` is its default), and `ensureInitialised()` already rebuilds the Y.Doc from SQLite on wake — the architecture *can* sleep. Four things prevent it in practice: @@ -50,7 +52,7 @@ Make the DO's awake time proportional to actual work, so hibernation between mes ## Phase 3 — Measurement and guardrails (S, ongoing) -- A weekly (or on-demand) GraphQL query of DO duration/requests/rows against budget, recorded in the repo (script in `tools/` or a doc), so the next quota cliff is visible days out, not at 500-time. +- `tools/do-usage.mjs`: per-day DO active time (converted to GB-s at the 128 MB billing size) and request counts from the GraphQL Analytics API, printed against the free-tier daily budgets with a warning at 70% and a failure exit at 100% — runnable ad hoc or from CI/cron. Requires `CLOUDFLARE_ACCOUNT_ID` plus a `CLOUDFLARE_API_TOKEN` scoped to *Account Analytics: Read*. - Revisit the awareness heartbeat cadence only if analytics show wake-per-message still dominating after Phases 1–2 (thinning presence updates trades cursor liveness for cost; not worth it until measured). ## Sequencing and expected effect diff --git a/tools/do-usage.mjs b/tools/do-usage.mjs new file mode 100644 index 00000000..0a9171c2 --- /dev/null +++ b/tools/do-usage.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Durable Objects usage vs. the free-tier daily budgets — the guardrail + * from docs/plans/2026-08-31-sleeping-tabs-plan.md, so the next quota + * cliff is visible days out instead of at 500-time. + * + * node tools/do-usage.mjs [--days 7] + * + * Needs: + * CLOUDFLARE_ACCOUNT_ID (already set for deploys) + * CLOUDFLARE_API_TOKEN an API token with "Account Analytics: Read" + * (dash.cloudflare.com → My Profile → API Tokens) + * + * Reads the GraphQL Analytics API: DO active time (the duration meter that + * exhausted on 2026-08-31) and request counts, per day, with headroom + * against the Workers Free limits. + */ + +const FREE_DURATION_GBS_PER_DAY = 13_000; // GB-s/day on Workers Free +const FREE_REQUESTS_PER_DAY = 100_000; +const DO_MEMORY_GB = 0.128; // every DO is billed at 128 MB + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; +const token = process.env.CLOUDFLARE_API_TOKEN; +const days = Math.max(1, Number(process.argv[process.argv.indexOf("--days") + 1]) || 7); + +if (!accountId || !token) { + console.error( + "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (Account Analytics: Read).", + ); + process.exit(1); +} + +const since = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); + +const query = `{ + viewer { + accounts(filter: {accountTag: "${accountId}"}) { + periodic: durableObjectsPeriodicGroups( + limit: 100 + filter: {date_geq: "${since}"} + orderBy: [date_ASC] + ) { + dimensions { date } + sum { activeTime } + } + invocations: durableObjectsInvocationsAdaptiveGroups( + limit: 100 + filter: {date_geq: "${since}"} + orderBy: [date_ASC] + ) { + dimensions { date } + sum { requests } + } + } + } +}`; + +const res = await fetch("https://api.cloudflare.com/client/v4/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), +}); +const body = await res.json(); + +if (!res.ok || body.errors?.length) { + console.error("GraphQL query failed:"); + console.error(JSON.stringify(body.errors ?? body, null, 2)); + process.exit(1); +} + +const account = body.data?.viewer?.accounts?.[0]; +if (!account) { + console.error("No account data returned — check the token's account scope."); + process.exit(1); +} + +const requestsByDate = new Map( + (account.invocations ?? []).map((g) => [g.dimensions.date, g.sum.requests]), +); + +console.log(`Durable Objects usage, last ${days} day(s) (free-tier budgets in %):\n`); +console.log("date active-hours GB-s duration% requests requests%"); + +let worst = 0; +for (const g of account.periodic ?? []) { + const date = g.dimensions.date; + // activeTime is reported in microseconds of wall-clock DO activity. + const activeSeconds = (g.sum.activeTime ?? 0) / 1e6; + const gbs = activeSeconds * DO_MEMORY_GB; + const durationPct = (gbs / FREE_DURATION_GBS_PER_DAY) * 100; + const requests = requestsByDate.get(date) ?? 0; + const requestsPct = (requests / FREE_REQUESTS_PER_DAY) * 100; + worst = Math.max(worst, durationPct, requestsPct); + console.log( + `${date} ${(activeSeconds / 3600).toFixed(1).padStart(10)}h ${Math.round(gbs) + .toString() + .padStart(7)} ${durationPct.toFixed(1).padStart(8)}% ${requests + .toString() + .padStart(9)} ${requestsPct.toFixed(1).padStart(8)}%`, + ); +} + +console.log(); +if (worst >= 100) { + console.log("⚠ A daily budget was exceeded — documents 500 until the daily reset (00:00 UTC)."); + process.exitCode = 2; +} else if (worst >= 70) { + console.log(`⚠ Peak day at ${worst.toFixed(0)}% of a free-tier budget — trending toward the cliff.`); + process.exitCode = 2; +} else { + console.log(`OK — peak day at ${worst.toFixed(0)}% of the free-tier budgets.`); +} From db991c205a9f3643d73ea06c81725fb79a0a2f47 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:11:43 -0700 Subject: [PATCH 76/83] Declare node globals for the usage script Co-Authored-By: Claude Fable 5 --- tools/do-usage.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/do-usage.mjs b/tools/do-usage.mjs index 0a9171c2..0fdb00f6 100644 --- a/tools/do-usage.mjs +++ b/tools/do-usage.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* global process, console, fetch */ /** * Durable Objects usage vs. the free-tier daily budgets — the guardrail * from docs/plans/2026-08-31-sleeping-tabs-plan.md, so the next quota From e286080691c70ae8d668920f06096e0a4f6e524c Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:52 -0700 Subject: [PATCH 77/83] Reorder the header; expose the dev server on the tailnet Tool menus sit left after the wordmark, then Edit, Share, and the doc id/expiry; connection status and the account menu move right of a flexible gap. Dev server binds all interfaces and allows .ts.net hosts. Co-Authored-By: Claude Fable 5 --- app/routes/doc.$id.tsx | 21 +++++++++++---------- vite.config.ts | 6 ++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/routes/doc.$id.tsx b/app/routes/doc.$id.tsx index 60be5e52..0a54c58e 100644 --- a/app/routes/doc.$id.tsx +++ b/app/routes/doc.$id.tsx @@ -95,7 +95,16 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul > vapor -
+
+ +
+
+ +
+
+ +
+
{id} {createdAt && ( @@ -103,18 +112,10 @@ function DocumentLayout({ id, createdAt }: { id: string; createdAt: number | nul )}
+
-
- -
-
- -
-
- -
setAgentsOpen(true)} />
diff --git a/vite.config.ts b/vite.config.ts index 49043558..000c5942 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,12 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + // Dev-only: bind all interfaces and accept tailnet hostnames, so the dev + // server is reachable at http://..ts.net:5173/. + server: { + host: true, + allowedHosts: [".ts.net"], + }, plugins: [ cloudflare({ viteEnvironment: { name: "ssr" } }), tailwindcss(), From bf6a0bcfb0838c70d168cd91d09d7f38353ab287 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:36:48 -0700 Subject: [PATCH 78/83] Drop the chevrons from the Edit and Share triggers Co-Authored-By: Claude Fable 5 --- app/components/ModeMenu.tsx | 11 +---------- app/components/ShareButton.tsx | 5 +---- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/app/components/ModeMenu.tsx b/app/components/ModeMenu.tsx index e511f583..615f7fca 100644 --- a/app/components/ModeMenu.tsx +++ b/app/components/ModeMenu.tsx @@ -4,14 +4,6 @@ import { hasSuggestionMarkup, processAllRanges } from "~/lib/suggestion-actions" import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "~/components/ui/menu"; import Icon from "~/components/Icon"; -function ChevronDown() { - return ( - - - - ); -} - /** * Header menu for the editing mode. Edit and Suggest switch modes, Markdown * toggles the source view; Accept all / Reject all apply to every pending @@ -37,11 +29,10 @@ export default function ModeMenu() {
diff --git a/app/components/ShareButton.tsx b/app/components/ShareButton.tsx index 40c81220..939b3116 100644 --- a/app/components/ShareButton.tsx +++ b/app/components/ShareButton.tsx @@ -28,13 +28,10 @@ export default function ShareButton() { From ad70f85dfeda82accad49a654598b8c1d696c7ab Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:50:01 -0700 Subject: [PATCH 79/83] Read the analytics token from CLOUDFLARE_ANALYTICS_TOKEN Named distinctly because an exported CLOUDFLARE_API_TOKEN shadows wrangler's OAuth login; the old name remains a fallback. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-31-sleeping-tabs-plan.md | 4 ++-- tools/do-usage.mjs | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-08-31-sleeping-tabs-plan.md b/docs/plans/2026-08-31-sleeping-tabs-plan.md index e2293178..7b62c3e5 100644 --- a/docs/plans/2026-08-31-sleeping-tabs-plan.md +++ b/docs/plans/2026-08-31-sleeping-tabs-plan.md @@ -2,7 +2,7 @@ **Goal:** An open vapor tab stops costing money when nobody is using it. Today every open tab — and every idling agent — pins its document's Durable Object in memory around the clock, which exhausted the free tier's daily duration quota on 2026-08-31 and took the whole product down for the day. -**Status:** Phases 1 and 2 shipped on the notes-import branch (PR #6, "Free-tier safeguards: sleeping tabs and DO wake hygiene") and are deployed. Phase 3's usage script lives at [tools/do-usage.mjs](../../tools/do-usage.mjs) — it needs a `CLOUDFLARE_API_TOKEN` with *Account Analytics: Read* (not yet provisioned; runs and fails cleanly without it). The webhook successor to `await_events` polling has its own plan: [MCP Events polyfill](2026-08-31-mcp-events-polyfill-plan.md). +**Status:** Phases 1 and 2 shipped on the notes-import branch (PR #6, "Free-tier safeguards: sleeping tabs and DO wake hygiene") and are deployed. Phase 3's usage script lives at [tools/do-usage.mjs](../../tools/do-usage.mjs) — it needs a `CLOUDFLARE_ANALYTICS_TOKEN` with *Account Analytics: Read* (provisioned 2026-09-01; named distinctly so it never shadows wrangler's OAuth login). The webhook successor to `await_events` polling has its own plan: [MCP Events polyfill](2026-08-31-mcp-events-polyfill-plan.md). ## Why documents never sleep @@ -52,7 +52,7 @@ Make the DO's awake time proportional to actual work, so hibernation between mes ## Phase 3 — Measurement and guardrails (S, ongoing) -- `tools/do-usage.mjs`: per-day DO active time (converted to GB-s at the 128 MB billing size) and request counts from the GraphQL Analytics API, printed against the free-tier daily budgets with a warning at 70% and a failure exit at 100% — runnable ad hoc or from CI/cron. Requires `CLOUDFLARE_ACCOUNT_ID` plus a `CLOUDFLARE_API_TOKEN` scoped to *Account Analytics: Read*. +- `tools/do-usage.mjs`: per-day DO active time (converted to GB-s at the 128 MB billing size) and request counts from the GraphQL Analytics API, printed against the free-tier daily budgets with a warning at 70% and a failure exit at 100% — runnable ad hoc or from CI/cron. Requires `CLOUDFLARE_ACCOUNT_ID` plus a `CLOUDFLARE_ANALYTICS_TOKEN` scoped to *Account Analytics: Read* (`CLOUDFLARE_API_TOKEN` accepted as a fallback). - Revisit the awareness heartbeat cadence only if analytics show wake-per-message still dominating after Phases 1–2 (thinning presence updates trades cursor liveness for cost; not worth it until measured). ## Sequencing and expected effect diff --git a/tools/do-usage.mjs b/tools/do-usage.mjs index 0fdb00f6..b8f956ba 100644 --- a/tools/do-usage.mjs +++ b/tools/do-usage.mjs @@ -8,9 +8,13 @@ * node tools/do-usage.mjs [--days 7] * * Needs: - * CLOUDFLARE_ACCOUNT_ID (already set for deploys) - * CLOUDFLARE_API_TOKEN an API token with "Account Analytics: Read" - * (dash.cloudflare.com → My Profile → API Tokens) + * CLOUDFLARE_ACCOUNT_ID (already set for deploys) + * CLOUDFLARE_ANALYTICS_TOKEN an API token with "Account Analytics: Read" + * (dash.cloudflare.com → My Profile → API Tokens). + * Named distinctly because an exported + * CLOUDFLARE_API_TOKEN shadows wrangler's OAuth + * login and can break deploys; that name still + * works here as a fallback. * * Reads the GraphQL Analytics API: DO active time (the duration meter that * exhausted on 2026-08-31) and request counts, per day, with headroom @@ -22,12 +26,12 @@ const FREE_REQUESTS_PER_DAY = 100_000; const DO_MEMORY_GB = 0.128; // every DO is billed at 128 MB const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; -const token = process.env.CLOUDFLARE_API_TOKEN; +const token = process.env.CLOUDFLARE_ANALYTICS_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN; const days = Math.max(1, Number(process.argv[process.argv.indexOf("--days") + 1]) || 7); if (!accountId || !token) { console.error( - "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (Account Analytics: Read).", + "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_TOKEN (Account Analytics: Read).", ); process.exit(1); } From c716e54652e0f6e9787cd700bcca14a026162d0f Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:15:10 -0700 Subject: [PATCH 80/83] Make webhook policy safe for set-and-forget subscribers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTL grants run to the document's remaining lifetime instead of 24h, and suspension requires sustained (>=1h) failure instead of five consecutive misses — a wake-on-webhook agent has no refresh daemon, and a lapsed subscription is exactly what would have woken it. Also adds the server-instructions update to the docs task. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-31-mcp-events-polyfill-plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-31-mcp-events-polyfill-plan.md b/docs/plans/2026-08-31-mcp-events-polyfill-plan.md index bec35222..bde4045d 100644 --- a/docs/plans/2026-08-31-mcp-events-polyfill-plan.md +++ b/docs/plans/2026-08-31-mcp-events-polyfill-plan.md @@ -41,8 +41,8 @@ The core (event log + cursor + subscription store + dispatcher) is protocol-agno - **Store**: a `subscriptions` table in the document's own DO (`id, principal, url, secret, name, arguments, cursor_floor, expires_at, failures, active`) — doc-scoped subscriptions live and die with the doc, which also gives TTL cleanup and the 99h expiry for free. - **Auth**: per the sketch, webhook mode requires a principal — so `events_subscribe` works **only through the OAuth door** (`/mcp`); the anonymous door gets poll only, refused with `-32012 Forbidden`. This also keeps the public-doc abuse surface closed (no anonymous "make vapor POST to arbitrary URLs"). - **Dispatch**: `recordEvent` → after the row insert, look up matching active subscriptions and POST each `EventOccurrence` with Standard Webhooks signatures via `waitUntil`. Coalescing: `document.changed` digests are already debounced server-side; mention/reply send immediately. -- **Retries & hygiene**: 2 retries with short backoff per delivery; `failures` increments on exhaustion and `active` flips false after 5 consecutive failures (the sketch's suspension semantics — a successful re-subscribe reactivates). HTTPS-only URLs; reject private-network literals (`localhost`, RFC1918, `.internal`) to keep the dispatcher from being an SSRF primitive. -- **TTL policy**: grant `min(suggested, 24h)` with a 5-minute floor; never grant no-expiry in v1 (the sketch lets servers refuse by granting finite). `refreshBefore` returned as ISO 8601; refresh is the idempotent re-subscribe the sketch specifies, including secret rotation semantics (replace; skip dual-signing in v1, documented). +- **Retries & hygiene**: 2 retries with short backoff per delivery; `active` flips false only after *sustained* failure — consecutive failures spanning at least an hour — so a receiver's deploy blip self-heals via retries instead of silently killing a set-and-forget subscription (a successful re-subscribe reactivates, per the sketch). HTTPS-only URLs; reject private-network literals (`localhost`, RFC1918, `.internal`) to keep the dispatcher from being an SSRF primitive. +- **TTL policy**: grant `min(suggested, remaining document lifetime)` with a 5-minute floor — subscriptions die with the document anyway, so short TTLs buy nothing while their refresh choreography breaks set-and-forget consumers (a wake-on-webhook agent has no daemon to refresh, and a lapsed subscription is exactly what would have woken it). Always finite, so never a no-expiry grant (the sketch lets servers refuse by granting finite). `refreshBefore` returned as ISO 8601; refresh is the idempotent re-subscribe the sketch specifies, including secret rotation semantics (replace; skip dual-signing in v1, documented). ## Tasks @@ -51,7 +51,7 @@ The core (event log + cursor + subscription store + dispatcher) is protocol-agno 3. **Layer 2 tools** in mcp-tools.ts (schema transliteration; `await_events` deprecation note). 4. **Layer 1 methods** in agents/mcp.ts via `setRequestHandler` + `capabilities.events` declaration + `_meta` draft tag. 5. **Tests**: signing vectors against the Standard Webhooks spec examples; subscribe/refresh/expire lifecycle; dispatch retry/suspend; poll parity with `await_events`; anonymous-door refusal; SSRF guard. -6. **Docs**: `/mcp` help page gains an events section; a short note filed to the WG repo as field-report feedback once it's running (they're soliciting exactly this). +6. **Docs & discovery**: `/mcp` help page gains an events section; the MCP server's `instructions` string gains an events paragraph (prefer `events_subscribe` over polling; respect `retryAfterMs`); a short note filed to the WG repo as field-report feedback once it's running (they're soliciting exactly this). ## Drift management (this is a draft, and it will move) From 94595f99350243e90f860713261bb4aa254787f9 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:38:47 -0700 Subject: [PATCH 81/83] Implement the MCP Events polyfill One protocol-agnostic core (agents/events.ts: catalog, cursors, Standard Webhooks signing, subscription ids, TTL policy) skinned three ways per the plan: - events/list|poll|subscribe|unsubscribe as spec-shaped JSON-RPC methods on VaporMcp, with the draft capability declared under experimental and the draft date tagged in _meta - events_* tool mirrors so today's clients can subscribe; await_events deprecated in favor of events_poll - a webhook dispatcher in DocumentAgent: subscriptions stored in the doc's own SQLite (dying with its 99h expiry), Standard-Webhooks signed POSTs off the hot path, retry then sustained-failure (>=1h) suspension, lazy TTL expiry, HTTPS-only with private-network rejection, and OAuth-door-only registration (-32012 for anonymous) TTL grants run to the document's remaining lifetime so set-and-forget subscribers never need a refresh daemon. Server instructions now teach the subscribe-over-poll norm; /mcp help page documents the surface. Fixes A-195 Co-Authored-By: Claude Fable 5 --- agents/document.ts | 323 ++++++++++++++++++ agents/events.ts | 255 ++++++++++++++ agents/mcp-tools.ts | 75 +++- agents/mcp.ts | 129 ++++++- app/lib/mcp-help.ts | 17 + app/shared/agent-protocol.ts | 6 +- .../integration/agents/document-agent.test.ts | 209 ++++++++++++ tests/unit/agents/events.test.ts | 143 ++++++++ 8 files changed, 1154 insertions(+), 3 deletions(-) create mode 100644 agents/events.ts create mode 100644 tests/unit/agents/events.test.ts diff --git a/agents/document.ts b/agents/document.ts index 52451a6b..df85291e 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -27,6 +27,22 @@ import { pmNodeToYElement, } from "../app/shared/rich-markdown"; import { chunkTyping } from "../app/lib/performance-chunks"; +import { + eventCatalog, + eventTypeByName, + buildOccurrence, + encodeCursor, + decodeCursor, + isValidWebhookSecret, + webhookUrlError, + subscriptionId, + signWebhook, + grantTtlMs, + DELIVERY_RETRY_DELAYS_MS, + SUSPEND_AFTER_FAILING_MS, + POLL_RETRY_AFTER_MS, + type EventOccurrence, +} from "./events"; import { encodeAgentAwareness, agentClientId, type AgentPresenceState } from "../app/lib/agent-awareness"; import type { ThreadData, ThreadReply } from "../app/shared/types"; @@ -255,6 +271,21 @@ class DocumentAgent extends Agent { created_at INTEGER ) `; + this.sql` + CREATE TABLE IF NOT EXISTS subscriptions ( + id TEXT PRIMARY KEY, + principal TEXT, + agent_name TEXT, + url TEXT, + secret TEXT, + name TEXT, + arguments TEXT, + expires_at INTEGER, + failing_since INTEGER, + active INTEGER, + created_at INTEGER + ) + `; // Load persisted state const rows = this.sql<{ value: ArrayBuffer }>` @@ -523,6 +554,8 @@ class DocumentAgent extends Agent { // Recorded events (mentions, thread replies, doc_changed digests) are // meaningless once the document they refer to is gone. this.sql`DELETE FROM events`; + // Webhook subscriptions die with the document. + this.sql`DELETE FROM subscriptions`; for (const finish of this.eventWaiters) finish(); this.eventWaiters = []; // Agent presence belongs to a document that no longer exists — drop it @@ -812,6 +845,12 @@ class DocumentAgent extends Agent { this.sql` INSERT INTO events (type, payload, created_at) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()}) `; + // ORDER BY + last element rather than MAX(): behaves identically on + // real SQLite and stays within what the test harness's SQL fake parses. + const seqRows = this.sql<{ seq: number }>` + SELECT seq FROM events ORDER BY seq ASC + `; + this.dispatchWebhooks(seqRows.length ? seqRows[seqRows.length - 1].seq : 0, type, payload); const waiters = this.eventWaiters; this.eventWaiters = []; for (const resolve of waiters) resolve(); @@ -900,6 +939,290 @@ class DocumentAgent extends Agent { return { events, cursor: lastSeq }; } + /* ================================================================ */ + /* MCP Events polyfill (draft Triggers & Events extension) */ + /* docs/plans/2026-08-31-mcp-events-polyfill-plan.md */ + /* ================================================================ */ + + /** When this document's auto-delete alarm fires (TTL grants cap here). */ + private docExpiresAt(): number { + const rows = this.sql<{ value: ArrayBuffer | Uint8Array }>` + SELECT value FROM doc_state WHERE key = 'createdAt' + `; + if (rows.length === 0) return Date.now() + DOCUMENT_TTL_MS; + const v = rows[0].value; + const bytes = v instanceof Uint8Array ? v : new Uint8Array(v); + const createdAt = new Float64Array(bytes.buffer, bytes.byteOffset, 1)[0]; + return createdAt + DOCUMENT_TTL_MS; + } + + /** The sketch's `events/list`: the event-type catalog. */ + async eventsList(identity: AgentIdentity): Promise< + | { events: ReturnType } + | { error: AgentError } + > { + const verified = await this.verifyIdentity(identity); + if ("error" in verified) return verified; + return { events: eventCatalog() }; + } + + /** + * The sketch's `events/poll`: request/response, no long hold (the hold + * lives in the deprecated agentAwaitEvents; polling here is meant to be + * cheap and paced by nextPollMs). + */ + async eventsPoll( + identity: AgentIdentity, + args: { name: string; cursor?: string | null; maxEvents?: number }, + ): Promise< + | { + events: EventOccurrence[]; + cursor: string | null; + truncated: boolean; + hasMore: boolean; + nextPollMs: number; + retryAfterMs?: number; + } + | { error: AgentError } + > { + const verified = await this.verifyIdentity(identity); + if ("error" in verified) return verified; + + const type = eventTypeByName(args.name); + if (!type) { + return { error: { code: "not_found", message: `Unknown event type: ${args.name}` } }; + } + const since = decodeCursor(args.cursor); + if (since === null) { + return { error: { code: "invalid_params", message: `Unparseable cursor: ${args.cursor}` } }; + } + const maxEvents = Math.min(Math.max(args.maxEvents ?? 50, 1), 200); + const self = verified.entry.name; + + const rows = this.sql` + SELECT * FROM events WHERE seq > ${since} ORDER BY seq ASC + `; + const out: EventOccurrence[] = []; + let lastSeq = since; + let hasMore = false; + for (const row of rows) { + if (out.length >= maxEvents) { + hasMore = true; + break; + } + lastSeq = Math.max(lastSeq, row.seq); + if (row.type !== type.internalType) continue; + let payload: unknown; + try { + payload = JSON.parse(row.payload) as unknown; + } catch { + continue; + } + if (type.addressed && (payload as { agent?: string }).agent !== self) continue; + const occurrence = buildOccurrence({ + docId: this.name, + seq: row.seq, + internalType: row.type, + payload, + createdAt: row.created_at, + }); + if (occurrence) out.push(occurrence); + } + + return { + events: out, + cursor: encodeCursor(lastSeq), + truncated: false, + hasMore, + nextPollMs: POLL_RETRY_AFTER_MS, + ...(out.length === 0 ? { retryAfterMs: POLL_RETRY_AFTER_MS } : {}), + }; + } + + /** + * The sketch's `events/subscribe` (webhook mode only): idempotent upsert + * keyed on (principal, url, name, arguments); re-subscribing refreshes + * the TTL and reactivates a suspended subscription. Requires an + * authenticated principal — the sketch forbids webhook mode on + * unauthenticated callers, and it also keeps the dispatcher from being + * an anonymous "make this server POST anywhere" primitive. + */ + async eventsSubscribe( + identity: AgentIdentity, + args: { name: string; url: string; secret: string; ttlMs?: number | null }, + ): Promise< + | { id: string; refreshBefore: string; cursor: string; truncated: boolean } + | { error: AgentError } + > { + const verified = await this.verifyIdentity(identity); + if ("error" in verified) return verified; + + if (identity.kind !== "principal") { + return { + error: { + code: "capability_denied", + message: "Webhook subscriptions require the authenticated /mcp door; the anonymous door may poll.", + }, + }; + } + if (!eventTypeByName(args.name)) { + return { error: { code: "not_found", message: `Unknown event type: ${args.name}` } }; + } + const urlError = webhookUrlError(args.url); + if (urlError) { + return { error: { code: "invalid_params", message: urlError } }; + } + if (!isValidWebhookSecret(args.secret)) { + return { + error: { + code: "invalid_params", + message: "delivery.secret must be whsec_ + base64 of 24-64 random bytes", + }, + }; + } + + const now = Date.now(); + const argumentsJson = JSON.stringify({ doc_id: this.name }); + const id = await subscriptionId(identity.id, args.url, args.name, argumentsJson); + const ttl = grantTtlMs(args.ttlMs, this.docExpiresAt(), now); + const expiresAt = now + ttl; + + // Idempotent upsert as delete+insert: a refresh replaces the secret, + // re-grants the TTL, clears the failure clock, and reactivates. + this.sql`DELETE FROM subscriptions WHERE id = ${id}`; + this.sql` + INSERT INTO subscriptions (id, principal, agent_name, url, secret, name, arguments, expires_at, failing_since, active, created_at) + VALUES (${id}, ${identity.id}, ${verified.entry.name}, ${args.url}, ${args.secret}, ${args.name}, ${argumentsJson}, ${expiresAt}, ${null}, ${1}, ${now}) + `; + + const seqRows = this.sql<{ seq: number }>` + SELECT seq FROM events ORDER BY seq ASC + `; + const watermark = encodeCursor(seqRows.length ? seqRows[seqRows.length - 1].seq : 0); + + return { id, refreshBefore: new Date(expiresAt).toISOString(), cursor: watermark, truncated: false }; + } + + /** The sketch's `events/unsubscribe`: eager teardown by subscription key. */ + async eventsUnsubscribe( + identity: AgentIdentity, + args: { name: string; url: string }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity); + if ("error" in verified) return verified; + if (identity.kind !== "principal") { + return { error: { code: "capability_denied", message: "Webhook subscriptions require the authenticated /mcp door." } }; + } + const argumentsJson = JSON.stringify({ doc_id: this.name }); + const id = await subscriptionId(identity.id, args.url, args.name, argumentsJson); + const rows = this.sql<{ id: string }>`SELECT id FROM subscriptions WHERE id = ${id}`; + if (rows.length === 0) { + return { error: { code: "not_found", message: "No such subscription" } }; + } + this.sql`DELETE FROM subscriptions WHERE id = ${id}`; + return { ok: true }; + } + + /** + * Dispatches a just-recorded event to matching webhook subscriptions. + * Runs off the hot path via waitUntil where available; each delivery + * retries briefly and marks sustained failure for suspension. + */ + private dispatchWebhooks(seq: number, internalType: string, payload: unknown): void { + const occurrence = buildOccurrence({ + docId: this.name, + seq, + internalType, + payload, + createdAt: Date.now(), + }); + if (!occurrence) return; // internal event type with no wire mapping + + const now = Date.now(); + const candidates = this.sql<{ + id: string; + url: string; + secret: string; + agent_name: string; + failing_since: number | null; + expires_at: number; + active: number; + }>` + SELECT id, url, secret, agent_name, failing_since, expires_at, active + FROM subscriptions WHERE name = ${occurrence.name} + `; + const subs: typeof candidates = []; + for (const sub of candidates) { + // Lazy TTL expiry: reap lapsed rows whenever we dispatch. + if (sub.expires_at < now) { + this.sql`DELETE FROM subscriptions WHERE id = ${sub.id}`; + continue; + } + if (sub.active !== 1) continue; + subs.push(sub); + } + if (subs.length === 0) return; + + const addressedTo = (payload as { agent?: string } | null)?.agent; + const type = eventTypeByName(occurrence.name); + + for (const sub of subs) { + if (type?.addressed && addressedTo !== sub.agent_name) continue; + const delivery = this.deliverWebhook(sub, occurrence); + // The Agents SDK exposes the DO's state as this.ctx; guard for test + // doubles that don't implement waitUntil. + const ctx = (this as unknown as { ctx?: { waitUntil?: (p: Promise) => void } }).ctx; + if (ctx?.waitUntil) ctx.waitUntil(delivery); + else void delivery; + } + } + + private async deliverWebhook( + sub: { id: string; url: string; secret: string; failing_since: number | null }, + occurrence: EventOccurrence, + ): Promise { + const body = JSON.stringify(occurrence); + const headers = { + "Content-Type": "application/json", + "X-MCP-Subscription-Id": sub.id, + ...(await signWebhook({ + secret: sub.secret, + messageId: occurrence.eventId, + timestampSeconds: Math.floor(Date.now() / 1000), + body, + })), + }; + + const attempts = [0, ...DELIVERY_RETRY_DELAYS_MS]; + for (let i = 0; i < attempts.length; i++) { + if (attempts[i] > 0) await sleep(attempts[i]); + try { + const res = await fetch(sub.url, { method: "POST", headers, body }); + if (res.ok) { + if (sub.failing_since !== null) { + this.sql`UPDATE subscriptions SET failing_since = ${null} WHERE id = ${sub.id}`; + } + return; + } + } catch { + // fall through to retry + } + } + + // All attempts failed: start (or continue) the sustained-failure clock; + // suspend only after failures have spanned SUSPEND_AFTER_FAILING_MS so + // a receiver's deploy blip self-heals instead of killing the + // subscription (a successful re-subscribe reactivates). + const now = Date.now(); + const since = sub.failing_since ?? now; + if (sub.failing_since === null) { + this.sql`UPDATE subscriptions SET failing_since = ${now} WHERE id = ${sub.id}`; + } + if (now - since >= SUSPEND_AFTER_FAILING_MS) { + this.sql`UPDATE subscriptions SET active = ${0} WHERE id = ${sub.id}`; + } + } + /** Revokes an agent's token by name. Idempotent. */ async revokeAgentEntry(name: string): Promise<{ ok: true } | { error: AgentError }> { this.ensureInitialised(); diff --git a/agents/events.ts b/agents/events.ts new file mode 100644 index 00000000..455f59cc --- /dev/null +++ b/agents/events.ts @@ -0,0 +1,255 @@ +/** + * The events core for the MCP Events polyfill — protocol-agnostic pieces + * shared by the tool mirrors, the spec-shaped `events/*` methods, and the + * DocumentAgent's webhook dispatcher. Shapes follow the MCP Triggers & + * Events WG design sketch (draft 2026-02-19); see + * docs/plans/2026-08-31-mcp-events-polyfill-plan.md. + * + * Deliberately imports nothing from the `agents` package so it stays + * unit-testable in plain Vitest (same convention as mcp-tools.ts). + */ + +/** Tag carried in `_meta` so draft-dialect traffic is distinguishable. */ +export const EVENTS_DRAFT_META_KEY = "fyi.vapor/events-draft"; +export const EVENTS_DRAFT_VERSION = "2026-02-19"; + +/* ---------- Event catalog ---------- */ + +/** Wire name ↔ the internal `events.type` column value. */ +export const EVENT_TYPES = [ + { + name: "document.changed", + internalType: "doc_changed", + description: + "Fires when the document's content changes (digested — one event per burst of edits, not per keystroke).", + delivery: ["poll", "webhook"] as const, + addressed: false, + }, + { + name: "mention", + internalType: "mention", + description: "Fires when this agent is @mentioned in the document text.", + delivery: ["poll", "webhook"] as const, + addressed: true, + }, + { + name: "thread.reply", + internalType: "thread_reply", + description: "Fires when a human replies in a comment thread this agent participated in.", + delivery: ["poll", "webhook"] as const, + addressed: true, + }, +] as const; + +export type EventTypeName = (typeof EVENT_TYPES)[number]["name"]; + +const DOC_ID_SCHEMA = { + type: "object", + properties: { + doc_id: { type: "string", description: "The 8-character document id (from its URL)." }, + }, + required: ["doc_id"], +} as const; + +/** The `events/list` result, per the sketch's EventType shape. */ +export function eventCatalog(): { + name: string; + description: string; + delivery: string[]; + inputSchema: unknown; + payloadSchema: unknown; +}[] { + return EVENT_TYPES.map((t) => ({ + name: t.name, + description: t.description, + delivery: [...t.delivery], + inputSchema: DOC_ID_SCHEMA, + payloadSchema: { + type: "object", + properties: { + doc_id: { type: "string" }, + ...(t.addressed ? { agent: { type: "string" } } : {}), + }, + }, + })); +} + +export function eventTypeByName(name: string) { + return EVENT_TYPES.find((t) => t.name === name) ?? null; +} + +export function wireNameForInternal(internalType: string): EventTypeName | null { + return EVENT_TYPES.find((t) => t.internalType === internalType)?.name ?? null; +} + +/* ---------- Cursors and occurrence ids ---------- */ + +/** Cursors are opaque to callers: the per-doc event seq, serialized. */ +export function encodeCursor(seq: number): string { + return `s${seq}`; +} + +export function decodeCursor(cursor: string | null | undefined): number | null { + if (cursor == null) return 0; // null = start from the beginning of the doc's log + const m = /^s(\d+)$/.exec(cursor); + return m ? Number(m[1]) : null; +} + +export function eventId(docId: string, seq: number): string { + return `${docId}:${seq}`; +} + +/** The sketch's EventOccurrence, as delivered by poll and webhook alike. */ +export interface EventOccurrence { + eventId: string; + name: string; + timestamp: string; + data: Record; + cursor: string; +} + +export function buildOccurrence(args: { + docId: string; + seq: number; + internalType: string; + payload: unknown; + createdAt: number; +}): EventOccurrence | null { + const name = wireNameForInternal(args.internalType); + if (!name) return null; + const payload = (args.payload ?? {}) as Record; + return { + eventId: eventId(args.docId, args.seq), + name, + timestamp: new Date(args.createdAt).toISOString(), + data: { doc_id: args.docId, ...payload }, + cursor: encodeCursor(args.seq), + }; +} + +/* ---------- Webhook subscriptions ---------- */ + +/** Standard Webhooks symmetric secret: whsec_ + base64 of 24–64 bytes. */ +export function isValidWebhookSecret(secret: string): boolean { + const m = /^whsec_([A-Za-z0-9+/=]+)$/.exec(secret); + if (!m) return false; + try { + const raw = atob(m[1]); + return raw.length >= 24 && raw.length <= 64; + } catch { + return false; + } +} + +/** + * HTTPS-only, and no private-network literals — the dispatcher must not be + * an SSRF primitive. Hostname checks are literal (a Worker cannot resolve + * DNS before fetching); a hostile DNS record is out of scope for v1. + */ +export function webhookUrlError(url: string): string | null { + let u: URL; + try { + u = new URL(url); + } catch { + return "delivery.url is not a valid URL"; + } + if (u.protocol !== "https:") return "delivery.url must be https"; + const host = u.hostname.toLowerCase(); + if ( + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host === "0.0.0.0" || + host === "[::1]" || + host === "::1" || + /^127\./.test(host) || + /^10\./.test(host) || + /^192\.168\./.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host) || + /^169\.254\./.test(host) || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) + ) { + return "delivery.url must not target a private network"; + } + return null; +} + +/** + * Deterministic subscription id over the sketch's key + * `(principal, delivery.url, name, arguments)` — a routing handle, not a + * capability. + */ +export async function subscriptionId( + principal: string, + url: string, + name: string, + argumentsJson: string, +): Promise { + const key = [principal, url, name, argumentsJson].join("\n"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)); + const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `sub_${hex.slice(0, 16)}`; +} + +/* ---------- Standard Webhooks signing ---------- */ + +/** + * Builds the Standard Webhooks headers for one delivery: + * `webhook-signature: v1,base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))`. + */ +export async function signWebhook(args: { + secret: string; + messageId: string; + timestampSeconds: number; + body: string; +}): Promise> { + const m = /^whsec_(.+)$/.exec(args.secret); + if (!m) throw new Error("not a whsec_ secret"); + const keyBytes = Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signed = `${args.messageId}.${args.timestampSeconds}.${args.body}`; + const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)); + const b64 = btoa(String.fromCharCode(...new Uint8Array(mac))); + return { + "webhook-id": args.messageId, + "webhook-timestamp": String(args.timestampSeconds), + "webhook-signature": `v1,${b64}`, + }; +} + +/* ---------- Delivery and TTL policy ---------- */ + +/** Grant floor: protects against refresh storms from misbehaving clients. */ +export const SUBSCRIPTION_TTL_FLOOR_MS = 5 * 60 * 1000; + +/** Retry delays after a failed delivery attempt (2 retries). */ +export const DELIVERY_RETRY_DELAYS_MS = [1_000, 5_000]; + +/** Suspend only after consecutive failures spanning at least this long. */ +export const SUSPEND_AFTER_FAILING_MS = 60 * 60 * 1000; + +/** Pacing hint returned with empty poll results. */ +export const POLL_RETRY_AFTER_MS = 30_000; + +/** + * TTL grant: min(suggested, remaining document lifetime), floored — a + * subscription dies with its document anyway, so refresh choreography is + * only imposed on clients who ask for less. Always finite (no-expiry + * requests get the document's remaining lifetime). + */ +export function grantTtlMs( + suggestedTtlMs: number | null | undefined, + docExpiresAt: number, + now: number, +): number { + const remaining = Math.max(docExpiresAt - now, SUBSCRIPTION_TTL_FLOOR_MS); + if (suggestedTtlMs == null) return remaining; + return Math.min(Math.max(suggestedTtlMs, SUBSCRIPTION_TTL_FLOOR_MS), remaining); +} diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index 27df28bb..fb8f2f70 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -22,6 +22,10 @@ export interface DocStub { agentJoin(identity: AgentIdentity, status?: string): Promise; agentLeave(identity: AgentIdentity): Promise; agentAwaitEvents(identity: AgentIdentity, args: unknown): Promise; + eventsList(identity: AgentIdentity): Promise; + eventsPoll(identity: AgentIdentity, args: unknown): Promise; + eventsSubscribe(identity: AgentIdentity, args: unknown): Promise; + eventsUnsubscribe(identity: AgentIdentity, args: unknown): Promise; } export interface ToolDeps { @@ -249,7 +253,7 @@ export const TOOLS: ToolDef[] = [ docTool({ name: "await_events", description: - "Poll for document events (mentions, thread replies, change digests) after a cursor. Returns as soon as anything is waiting, or empty when the timeout (capped at 15s) elapses. An empty result includes retryAfterMs — wait at least that long before polling again; hot-looping this tool keeps the document's server pinned.", + "DEPRECATED — prefer events_poll (and events_subscribe for push). Long-polls for document events after a cursor; capped at 15s, empty results carry retryAfterMs.", schema: { since_cursor: z .number() @@ -268,4 +272,73 @@ export const TOOLS: ToolDef[] = [ }); }, }), + + docTool({ + name: "events_list", + description: + "List the document's event types (experimental — mirrors the draft MCP Events extension): name, delivery modes, argument and payload schemas. Use events_subscribe for webhook push or events_poll to pull.", + schema: {}, + call: (stub, identity) => stub.eventsList(identity), + }), + + docTool({ + name: "events_poll", + description: + "Poll one event type for occurrences after a cursor (experimental — mirrors the draft MCP Events extension). Returns events plus a new cursor; empty results include retryAfterMs — wait at least that long before polling again. Prefer events_subscribe when you have a webhook receiver.", + schema: { + name: z.string().describe("Event type name from events_list, e.g. mention."), + cursor: z + .string() + .nullable() + .optional() + .describe("Opaque cursor from a previous poll; omit or null to start from the beginning of the document's log."), + max_events: z.number().optional().describe("Cap on returned events (default 50, max 200)."), + }, + call: (stub, identity, args) => + stub.eventsPoll(identity, { + name: args.name as string, + cursor: args.cursor as string | null | undefined, + maxEvents: args.max_events as number | undefined, + }), + }), + + docTool({ + name: "events_subscribe", + description: + "Register a webhook for an event type (experimental — mirrors the draft MCP Events extension). The server POSTs each occurrence to your HTTPS URL, signed per Standard Webhooks with your whsec_ secret. Requires the authenticated /mcp door. Idempotent per (you, url, name): re-subscribing refreshes the TTL — which runs to the document's remaining lifetime by default — and reactivates a suspended subscription.", + schema: { + name: z.string().describe("Event type name from events_list, e.g. mention."), + url: z.string().describe("HTTPS webhook URL to POST occurrences to."), + secret: z + .string() + .describe("Client-generated Standard Webhooks secret: whsec_ + base64 of 24-64 random bytes. You verify deliveries with it."), + ttl_ms: z + .number() + .nullable() + .optional() + .describe("Suggested subscription lifetime in ms; omit or null for the document's remaining lifetime."), + }, + call: (stub, identity, args) => + stub.eventsSubscribe(identity, { + name: args.name as string, + url: args.url as string, + secret: args.secret as string, + ttlMs: args.ttl_ms as number | null | undefined, + }), + }), + + docTool({ + name: "events_unsubscribe", + description: + "Remove a webhook subscription created with events_subscribe (experimental — mirrors the draft MCP Events extension). Keyed by event name + url for the calling identity.", + schema: { + name: z.string().describe("Event type name the subscription was created for."), + url: z.string().describe("The webhook URL the subscription delivers to."), + }, + call: (stub, identity, args) => + stub.eventsUnsubscribe(identity, { + name: args.name as string, + url: args.url as string, + }), + }), ]; diff --git a/agents/mcp.ts b/agents/mcp.ts index 298fc708..0c165140 100644 --- a/agents/mcp.ts +++ b/agents/mcp.ts @@ -14,6 +14,7 @@ import { McpAgent } from "agents/mcp"; import { getAgentByName } from "agents"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { TOOLS, @@ -22,6 +23,7 @@ import { anonymousAgentLabel, type DocStub, } from "./mcp-tools"; +import { eventCatalog, EVENTS_DRAFT_META_KEY, EVENTS_DRAFT_VERSION } from "./events"; import { generateDocumentId } from "../app/shared/constants"; import { slugifyAgentName, @@ -41,13 +43,42 @@ export interface VaporMcpProps extends Record { const DEFAULT_ORIGIN = "https://vapor.fyi"; +const SERVER_INSTRUCTIONS = `vapor hosts live collaborative markdown documents; you join them as a named collaborator. Read with read_document, edit with insert/replace (write capability), propose with suggest, and discuss with comment/reply. Blocks are addressed by persistent anchors from read_document. + +Events: documents emit mention, thread.reply, and document.changed events. If you have a webhook receiver, prefer events_subscribe (push, signed per Standard Webhooks) over polling; otherwise poll with events_poll and always wait at least retryAfterMs between empty polls - hot-looping pins the document's server. The events surface is experimental and mirrors the draft MCP Events extension (${EVENTS_DRAFT_VERSION}).`; + +/** + * The sketch's JSON-RPC error codes for the events extension. AgentError + * codes from the DocumentAgent map onto them at this layer. + */ +const EVENTS_ERROR_CODES: Record = { + not_found: -32011, + doc_not_found: -32011, + capability_denied: -32012, + invalid_token: -32012, + rate_limited: -32013, + invalid_params: -32602, +}; + +function throwEventsError(error: { code: string; message: string }): never { + throw new McpError(EVENTS_ERROR_CODES[error.code] ?? -32603, error.message); +} + /** Every tool — errors included — returns its result as JSON text content. */ function jsonContent(result: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; } export class VaporMcp extends McpAgent, VaporMcpProps> { - server = new McpServer({ name: "vapor", version: "1.0.0" }); + server = new McpServer( + { name: "vapor", version: "1.0.0" }, + { + instructions: SERVER_INSTRUCTIONS, + // The draft extension's capability, declared under `experimental` + // until the SEP ratifies and the SDK learns a first-class slot. + capabilities: { experimental: { events: {} } }, + }, + ); /** Session-cached counterpart slug + label for the principal path. */ private agentSlug: string | null = null; @@ -100,6 +131,8 @@ export class VaporMcp extends McpAgent, VaporMcpProps const getStub = (docId: string) => getAgentByName(this.env.DocumentAgent, docId) as unknown as Promise; + this.registerEventsMethods(getStub); + for (const tool of TOOLS) { this.server.registerTool( tool.name, @@ -161,4 +194,98 @@ export class VaporMcp extends McpAgent, VaporMcpProps }, ); } + + /** + * Layer 1 of the events polyfill: the draft extension's own JSON-RPC + * methods, shapes copied from the WG design sketch and tagged with the + * draft date in _meta. Today's clients use the events_* tool mirrors; + * these exist so spec-native SDKs work unchanged when they arrive. + */ + private registerEventsMethods(getStub: (docId: string) => Promise) { + const argumentsSchema = z.object({ doc_id: z.string() }); + const low = this.server.server; + const unwrap = (result: T): T => { + if (result && typeof result === "object" && "error" in result) { + throwEventsError((result as { error: { code: string; message: string } }).error); + } + return result; + }; + + low.setRequestHandler( + z.object({ method: z.literal("events/list"), params: z.object({}).passthrough().optional() }), + async () => ({ + events: eventCatalog(), + _meta: { [EVENTS_DRAFT_META_KEY]: EVENTS_DRAFT_VERSION }, + }), + ); + + low.setRequestHandler( + z.object({ + method: z.literal("events/poll"), + params: z.object({ + name: z.string(), + arguments: argumentsSchema, + cursor: z.string().nullable().optional(), + maxEvents: z.number().optional(), + }), + }), + async (req) => { + const { name, arguments: a, cursor, maxEvents } = req.params; + const identity = await this.identity(); + const stub = await getStub(a.doc_id); + return unwrap(await stub.eventsPoll(identity, { name, cursor, maxEvents })) as Record< + string, + unknown + >; + }, + ); + + low.setRequestHandler( + z.object({ + method: z.literal("events/subscribe"), + params: z.object({ + name: z.string(), + arguments: argumentsSchema, + delivery: z.object({ + mode: z.literal("webhook"), + url: z.string(), + secret: z.string(), + }), + cursor: z.string().nullable().optional(), + ttlMs: z.number().nullable().optional(), + }), + }), + async (req) => { + const { name, arguments: a, delivery, ttlMs } = req.params; + const identity = await this.identity(); + const stub = await getStub(a.doc_id); + return unwrap( + await stub.eventsSubscribe(identity, { + name, + url: delivery.url, + secret: delivery.secret, + ttlMs, + }), + ) as Record; + }, + ); + + low.setRequestHandler( + z.object({ + method: z.literal("events/unsubscribe"), + params: z.object({ + name: z.string(), + arguments: argumentsSchema, + delivery: z.object({ url: z.string() }), + }), + }), + async (req) => { + const { name, arguments: a, delivery } = req.params; + const identity = await this.identity(); + const stub = await getStub(a.doc_id); + unwrap(await stub.eventsUnsubscribe(identity, { name, url: delivery.url })); + return {}; + }, + ); + } } diff --git a/app/lib/mcp-help.ts b/app/lib/mcp-help.ts index 3070af84..d91331f4 100644 --- a/app/lib/mcp-help.ts +++ b/app/lib/mcp-help.ts @@ -117,6 +117,23 @@ export function mcpHelpHtml(origin: string): string { flow your client discovers automatically.

+

Events & webhooks (experimental)

+

+ Documents emit mention, thread.reply, and + document.changed events. Instead of polling, an agent on the + authenticated door can register a webhook with the + events_subscribe tool: pass an HTTPS URL and a client-generated + secret (whsec_ + base64 of 24–64 random bytes), and vapor + POSTs each occurrence there, signed per + Standard Webhooks + (webhook-id / webhook-timestamp / + webhook-signature headers). Subscriptions last the document's + remaining lifetime by default and are refreshed by re-subscribing. + events_poll pulls the same events by cursor — wait at + least retryAfterMs between empty polls. This surface mirrors the + draft MCP Events extension and will track the standard as it ratifies. +

+ `; diff --git a/app/shared/agent-protocol.ts b/app/shared/agent-protocol.ts index 298593ae..3857c55a 100644 --- a/app/shared/agent-protocol.ts +++ b/app/shared/agent-protocol.ts @@ -59,7 +59,11 @@ export type AgentErrorCode = | "invalid_name" | "thread_not_found" /** Markdown the editor's mark model can't represent (CriticMarkup substitution). */ - | "unsupported_markup"; + | "unsupported_markup" + /** Events polyfill: a referenced event type or subscription doesn't exist (sketch -32011). */ + | "not_found" + /** Events polyfill: statically invalid arguments — bad URL, bad whsec_ secret, bad cursor (sketch -32602). */ + | "invalid_params"; export const AGENT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts index ae9ba869..6a3e494e 100644 --- a/tests/integration/agents/document-agent.test.ts +++ b/tests/integration/agents/document-agent.test.ts @@ -2050,4 +2050,213 @@ describe("DocumentAgent", () => { expect(mockTables.get("events") ?? []).toEqual([]); }); }); + + /* ================================================================ */ + /* Events polyfill (draft MCP Triggers & Events extension) */ + /* ================================================================ */ + + describe("events polyfill", () => { + const SECRET = "whsec_" + btoa("0123456789abcdef01234567"); + const URL = "https://relay.example.com/hook"; + + async function setupDoc(caps: AgentCapability[] = ["suggest", "comment"]) { + const agent = makeAgent(); + await agent.onRequest(new Request("https://do/", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# Title\n\nBody." }), + })); + const id = identity({ caps }); + // Enroll the subscriber so @scribe mentions register against the roster. + await agent.agentJoin(id); + return { agent, id }; + } + + function subsRows() { + return (mockTables.get("subscriptions") ?? []) as Array>; + } + + function mention(agent: InstanceType, text: string) { + const client = connectYjsClient(agent); + const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; + const ytext = para.get(0) as Y.XmlText; + ytext.insert(ytext.length, text); + cleanup(client); + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("lists the event catalog", async () => { + const { agent, id } = await setupDoc(); + const r = await agent.eventsList(id); + expect("events" in r && r.events.map((e) => e.name)).toEqual([ + "document.changed", + "mention", + "thread.reply", + ]); + }); + + it("polls one event type with cursor advance and addressed filtering", async () => { + const { agent, id } = await setupDoc(); + mention(agent, " ping @scribe please"); + + const first = await agent.eventsPoll(id, { name: "mention" }); + if ("error" in first) throw new Error(first.error.message); + expect(first.events).toHaveLength(1); + expect(first.events[0]).toMatchObject({ + name: "mention", + data: { doc_id: "test-doc", agent: "scribe" }, + }); + + // Same events, different agent: addressed filtering yields nothing. + const other = await agent.eventsPoll(identity({ id: "email:b@x.com", name: "other" }), { + name: "mention", + }); + if ("error" in other) throw new Error(other.error.message); + expect(other.events).toHaveLength(0); + expect(other.retryAfterMs).toBeGreaterThan(0); + + // Cursor advances past everything scanned. + const again = await agent.eventsPoll(id, { name: "mention", cursor: first.cursor }); + if ("error" in again) throw new Error(again.error.message); + expect(again.events).toHaveLength(0); + }); + + it("rejects unknown event names and bad cursors", async () => { + const { agent, id } = await setupDoc(); + expect(await agent.eventsPoll(id, { name: "nope" })).toMatchObject({ + error: { code: "not_found" }, + }); + expect(await agent.eventsPoll(id, { name: "mention", cursor: "zzz" })).toMatchObject({ + error: { code: "invalid_params" }, + }); + }); + + it("refuses webhook subscriptions from anonymous identities", async () => { + const { agent } = await setupDoc(); + const anon = identity({ kind: "anonymous", id: "anon:s1", owner: null }); + const r = await agent.eventsSubscribe(anon, { name: "mention", url: URL, secret: SECRET }); + expect(r).toMatchObject({ error: { code: "capability_denied" } }); + }); + + it("validates the secret and the URL", async () => { + const { agent, id } = await setupDoc(); + expect( + await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: "whsec_short" }), + ).toMatchObject({ error: { code: "invalid_params" } }); + expect( + await agent.eventsSubscribe(id, { name: "mention", url: "https://10.0.0.1/h", secret: SECRET }), + ).toMatchObject({ error: { code: "invalid_params" } }); + expect( + await agent.eventsSubscribe(id, { name: "mention", url: "http://relay.example.com/h", secret: SECRET }), + ).toMatchObject({ error: { code: "invalid_params" } }); + }); + + it("grants TTL to the document's remaining lifetime and upserts idempotently", async () => { + const { agent, id } = await setupDoc(); + const r1 = await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + if ("error" in r1) throw new Error(r1.error.message); + // Fresh doc: remaining lifetime is ~99h, far beyond the old 24h cap. + expect(new Date(r1.refreshBefore).getTime() - Date.now()).toBeGreaterThan(90 * 3600 * 1000); + expect(r1.id).toMatch(/^sub_/); + + const r2 = await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + if ("error" in r2) throw new Error(r2.error.message); + expect(r2.id).toBe(r1.id); + expect(subsRows()).toHaveLength(1); + }); + + it("unsubscribes by key and errors on a missing subscription", async () => { + const { agent, id } = await setupDoc(); + await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + expect(await agent.eventsUnsubscribe(id, { name: "mention", url: URL })).toEqual({ ok: true }); + expect(subsRows()).toHaveLength(0); + expect(await agent.eventsUnsubscribe(id, { name: "mention", url: URL })).toMatchObject({ + error: { code: "not_found" }, + }); + }); + + it("delivers a signed webhook on a matching event", async () => { + const { agent, id } = await setupDoc(); + const calls: { url: string; headers: Record; body: string }[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init: RequestInit) => { + calls.push({ + url: String(url), + headers: init.headers as Record, + body: String(init.body), + }); + return new Response("ok", { status: 200 }); + })); + + await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + mention(agent, " hey @scribe look"); + await new Promise((r) => setTimeout(r, 10)); + + expect(calls).toHaveLength(1); + const call = calls[0]; + expect(call.url).toBe(URL); + expect(call.headers["X-MCP-Subscription-Id"]).toMatch(/^sub_/); + expect(call.headers["webhook-id"]).toMatch(/^test-doc:\d+$/); + const body = JSON.parse(call.body) as { name: string; data: { agent: string } }; + expect(body).toMatchObject({ name: "mention", data: { agent: "scribe" } }); + + // Signature verifies against the raw secret bytes. + const { createHmac } = await import("node:crypto"); + const expected = createHmac("sha256", Buffer.from("0123456789abcdef01234567", "binary")) + .update(`${call.headers["webhook-id"]}.${call.headers["webhook-timestamp"]}.${call.body}`) + .digest("base64"); + expect(call.headers["webhook-signature"]).toBe(`v1,${expected}`); + }); + + it("does not deliver another agent's mention", async () => { + const { agent, id } = await setupDoc(); + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + // Enroll a second agent, then mention only that one. + await agent.agentJoin(identity({ id: "email:b@x.com", name: "other" })); + mention(agent, " hi @other only"); + await new Promise((r) => setTimeout(r, 10)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("suspends only after sustained failure, and re-subscribe reactivates", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const { agent, id } = await setupDoc(); + vi.stubGlobal("fetch", vi.fn(async () => new Response("no", { status: 500 }))); + + await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + + // Mention notifications dedupe per (text node, agent) while the name + // stays in the block, so to re-mention we remove the first mention + // (forgetting the name) before inserting the second. + const client = connectYjsClient(agent); + const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement; + const ytext = para.get(0) as Y.XmlText; + const base = ytext.length; + ytext.insert(base, " one @scribe"); + await vi.advanceTimersByTimeAsync(10_000); // burn the retry ladder + expect(subsRows()[0].failing_since).not.toBeNull(); + expect(subsRows()[0].active).toBe(1); + + // An hour later, still failing: now it suspends. + await vi.advanceTimersByTimeAsync(61 * 60 * 1000); + ytext.delete(base, " one @scribe".length); + ytext.insert(base, " two @scribe"); + await vi.advanceTimersByTimeAsync(10_000); + expect(subsRows()[0].active).toBe(0); + cleanup(client); + + // Re-subscribe reactivates and clears the failure clock. + const r = await agent.eventsSubscribe(id, { name: "mention", url: URL, secret: SECRET }); + if ("error" in r) throw new Error(r.error.message); + expect(subsRows()[0].active).toBe(1); + expect(subsRows()[0].failing_since).toBeNull(); + }); + }); + }); diff --git a/tests/unit/agents/events.test.ts b/tests/unit/agents/events.test.ts new file mode 100644 index 00000000..37f20c59 --- /dev/null +++ b/tests/unit/agents/events.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { createHmac } from "node:crypto"; +import { + eventCatalog, + eventTypeByName, + encodeCursor, + decodeCursor, + eventId, + buildOccurrence, + isValidWebhookSecret, + webhookUrlError, + subscriptionId, + signWebhook, + grantTtlMs, + SUBSCRIPTION_TTL_FLOOR_MS, +} from "~/../agents/events"; + +describe("event catalog", () => { + it("lists the three event types with schemas and delivery modes", () => { + const catalog = eventCatalog(); + expect(catalog.map((e) => e.name)).toEqual(["document.changed", "mention", "thread.reply"]); + for (const e of catalog) { + expect(e.delivery).toContain("poll"); + expect(e.delivery).toContain("webhook"); + expect(e.inputSchema).toMatchObject({ required: ["doc_id"] }); + } + }); + + it("maps names to internal types", () => { + expect(eventTypeByName("mention")?.internalType).toBe("mention"); + expect(eventTypeByName("thread.reply")?.internalType).toBe("thread_reply"); + expect(eventTypeByName("document.changed")?.internalType).toBe("doc_changed"); + expect(eventTypeByName("nope")).toBeNull(); + }); +}); + +describe("cursors and occurrences", () => { + it("round-trips cursors and treats null as the log start", () => { + expect(decodeCursor(encodeCursor(42))).toBe(42); + expect(decodeCursor(null)).toBe(0); + expect(decodeCursor(undefined)).toBe(0); + expect(decodeCursor("garbage")).toBeNull(); + }); + + it("builds occurrences with stable ids and doc_id merged into data", () => { + const occ = buildOccurrence({ + docId: "abcd1234", + seq: 7, + internalType: "mention", + payload: { agent: "scribe", text: "hi @scribe" }, + createdAt: 1_700_000_000_000, + }); + expect(occ).toMatchObject({ + eventId: eventId("abcd1234", 7), + name: "mention", + cursor: "s7", + data: { doc_id: "abcd1234", agent: "scribe" }, + }); + expect(buildOccurrence({ docId: "x", seq: 1, internalType: "internal_only", payload: {}, createdAt: 0 })).toBeNull(); + }); +}); + +describe("webhook secrets and URLs", () => { + it("accepts whsec_ + base64 of 24-64 bytes and rejects everything else", () => { + const good = "whsec_" + btoa("a".repeat(32)); + expect(isValidWebhookSecret(good)).toBe(true); + expect(isValidWebhookSecret("whsec_" + btoa("short"))).toBe(false); + expect(isValidWebhookSecret("whsec_" + btoa("a".repeat(65)))).toBe(false); + expect(isValidWebhookSecret("nope_" + btoa("a".repeat(32)))).toBe(false); + expect(isValidWebhookSecret("whsec_%%%")).toBe(false); + }); + + it("requires https and rejects private-network literals", () => { + expect(webhookUrlError("https://relay.example.com/hook")).toBeNull(); + expect(webhookUrlError("http://relay.example.com/hook")).toMatch(/https/); + expect(webhookUrlError("not a url")).toMatch(/valid URL/); + for (const host of [ + "localhost", + "sub.localhost", + "box.internal", + "127.0.0.1", + "10.1.2.3", + "192.168.0.9", + "172.16.5.5", + "169.254.1.1", + "100.77.101.103", + ]) { + expect(webhookUrlError(`https://${host}/hook`), host).toMatch(/private network/); + } + }); +}); + +describe("subscription ids", () => { + it("is deterministic over the subscription key and distinct across keys", async () => { + const a = await subscriptionId("email:a@x.com", "https://r.example/h", "mention", '{"doc_id":"d1"}'); + const b = await subscriptionId("email:a@x.com", "https://r.example/h", "mention", '{"doc_id":"d1"}'); + const c = await subscriptionId("email:b@x.com", "https://r.example/h", "mention", '{"doc_id":"d1"}'); + expect(a).toBe(b); + expect(a).not.toBe(c); + expect(a).toMatch(/^sub_[0-9a-f]{16}$/); + }); +}); + +describe("Standard Webhooks signing", () => { + it("produces a signature verifiable with the raw secret bytes", async () => { + const rawSecret = "0123456789abcdef01234567"; // 24 bytes + const secret = "whsec_" + btoa(rawSecret); + const body = '{"eventId":"d:1"}'; + const headers = await signWebhook({ + secret, + messageId: "d:1", + timestampSeconds: 1_700_000_000, + body, + }); + + expect(headers["webhook-id"]).toBe("d:1"); + expect(headers["webhook-timestamp"]).toBe("1700000000"); + + const expected = createHmac("sha256", Buffer.from(rawSecret, "binary")) + .update(`d:1.1700000000.${body}`) + .digest("base64"); + expect(headers["webhook-signature"]).toBe(`v1,${expected}`); + }); +}); + +describe("TTL grants", () => { + const now = 1_000_000; + const expiry = now + 50 * 60 * 60 * 1000; // doc dies in 50h + + it("defaults (and no-expiry requests) to the document's remaining lifetime", () => { + expect(grantTtlMs(undefined, expiry, now)).toBe(expiry - now); + expect(grantTtlMs(null, expiry, now)).toBe(expiry - now); + }); + + it("honours shorter suggestions and floors unreasonably short ones", () => { + expect(grantTtlMs(60 * 60 * 1000, expiry, now)).toBe(60 * 60 * 1000); + expect(grantTtlMs(1_000, expiry, now)).toBe(SUBSCRIPTION_TTL_FLOOR_MS); + }); + + it("caps suggestions beyond the document's lifetime", () => { + expect(grantTtlMs(1000 * 60 * 60 * 1000, expiry, now)).toBe(expiry - now); + }); +}); From a68036cce1bf2fcdfc3ef0e5516a78ac94ce3301 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:52:35 -0700 Subject: [PATCH 82/83] Add the vapor-to-routine mention relay Verifies Standard Webhooks deliveries and forwards the event body to a Claude routine's /fire endpoint. Holds only the whsec and the routine-scoped fire token. Co-Authored-By: Claude Fable 5 --- relay/index.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++ relay/wrangler.jsonc | 9 ++++++ 2 files changed, 76 insertions(+) create mode 100644 relay/index.ts create mode 100644 relay/wrangler.jsonc diff --git a/relay/index.ts b/relay/index.ts new file mode 100644 index 00000000..e08d3289 --- /dev/null +++ b/relay/index.ts @@ -0,0 +1,67 @@ +/** + * vapor → Claude routine relay: the ~30 lines that turn a vapor webhook + * into a woken Claude session (scenario 2 of the events polyfill plan). + * + * Verifies the Standard Webhooks signature from vapor, then forwards the + * event body as `text` to the routine's /fire endpoint. Holds exactly two + * secrets: the whsec used at events_subscribe time, and the routine's own + * fire token (scoped to firing that one routine — the narrowest credential + * this job could have). + * + * Deploy: npx wrangler deploy -c relay/wrangler.jsonc + * Secrets: WEBHOOK_SECRET (whsec_…), FIRE_TOKEN (sk-ant-oat01-…) + */ + +interface RelayEnv { + WEBHOOK_SECRET: string; + FIRE_TOKEN: string; + ROUTINE_ID: string; +} + +const TIMESTAMP_TOLERANCE_S = 300; + +export default { + async fetch(request: Request, env: RelayEnv): Promise { + if (request.method !== "POST") { + return new Response("vapor mention relay: POST Standard-Webhooks deliveries here", { status: 200 }); + } + + const body = await request.text(); + const id = request.headers.get("webhook-id"); + const ts = request.headers.get("webhook-timestamp"); + const sig = request.headers.get("webhook-signature"); + if (!id || !ts || !sig) return new Response("missing signature headers", { status: 400 }); + if (Math.abs(Date.now() / 1000 - Number(ts)) > TIMESTAMP_TOLERANCE_S) { + return new Response("stale timestamp", { status: 400 }); + } + + const raw = Uint8Array.from(atob(env.WEBHOOK_SECRET.slice("whsec_".length)), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const mac = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${ts}.${body}`)), + ); + const expected = "v1," + btoa(String.fromCharCode(...mac)); + const provided = sig.split(/\s+/).find((s) => s.startsWith("v1,")); + if (provided !== expected) return new Response("bad signature", { status: 401 }); + + const fire = await fetch( + `https://api.anthropic.com/v1/claude_code/routines/${env.ROUTINE_ID}/fire`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.FIRE_TOKEN}`, + "anthropic-beta": "experimental-cc-routine-2026-04-01", + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + body: JSON.stringify({ text: body }), + }, + ); + + if (!fire.ok) { + console.error(`fire failed: ${fire.status} ${await fire.text()}`); + return new Response("fire failed", { status: 502 }); + } + return new Response("fired", { status: 200 }); + }, +}; diff --git a/relay/wrangler.jsonc b/relay/wrangler.jsonc new file mode 100644 index 00000000..5ce4ec50 --- /dev/null +++ b/relay/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vapor-mention-relay", + "main": "index.ts", + "compatibility_date": "2026-08-01", + "vars": { + "ROUTINE_ID": "trig_01SV2swZ5wW32LRWAfF1zpC9" + } +} From 47fbf1fc549c64c126352b7b299bbeb4da0961fa Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff <563095+alcor@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:49:51 -0700 Subject: [PATCH 83/83] UI polish: header start-editing button, avatar initials, icon and copy updates - Home: new tagline and meta description covering agent collaboration, plus an MCP connect command encouraging agents to iterate in the browser - Move the start-editing marquee button into the top toolbar - Add Avatar component with initials placeholder circles; use it in thread panels and the header menu (top-right avatar now 32x32) - Hide comment resolve/menu icons until hover - Auto theme now uses the laptop (computer) icon - Material Symbols weight set to 300 - Share menu items get link/download icons Co-Authored-By: Claude Opus 4.8 --- app/app.css | 2 +- app/components/Avatar.tsx | 46 ++++++++++++ app/components/HeaderMenu.tsx | 21 ++++-- app/components/MobilePanel.tsx | 6 -- app/components/OnboardingBanner.tsx | 32 ++++---- app/components/ShareButton.tsx | 11 ++- app/components/ThemeSelector.tsx | 4 +- app/components/ThreadPanel.tsx | 25 +++---- app/root.tsx | 2 +- app/routes/doc.$id.tsx | 4 +- app/routes/home.tsx | 109 ++++++++++++++++------------ 11 files changed, 165 insertions(+), 97 deletions(-) create mode 100644 app/components/Avatar.tsx diff --git a/app/app.css b/app/app.css index 162df18e..c11b21c4 100644 --- a/app/app.css +++ b/app/app.css @@ -278,7 +278,7 @@ body { word-wrap: normal; direction: ltr; vertical-align: -0.28em; - font-variation-settings: "opsz" 20; + font-variation-settings: "opsz" 20, "wght" 300; } /* Monochrome animal glyphs (Noto Emoji) — tinted via `color`. */ diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx new file mode 100644 index 00000000..35af2f05 --- /dev/null +++ b/app/components/Avatar.tsx @@ -0,0 +1,46 @@ +function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "?"; + if (words.length === 1) return words[0][0].toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); +} + +/** + * Circular avatar: photo if present, anonymous animal glyph if present, + * otherwise a placeholder circle with the author's initials. + */ +export default function Avatar({ + name, + avatar, + animal, + color, + className = "h-7 w-7", +}: { + name: string; + avatar?: string | null; + animal?: string; + color?: string; + className?: string; +}) { + if (avatar) { + return ; + } + if (animal) { + return ( + + {animal} + + ); + } + return ( + + {initials(name)} + + ); +} diff --git a/app/components/HeaderMenu.tsx b/app/components/HeaderMenu.tsx index eba03b0c..df2c9e00 100644 --- a/app/components/HeaderMenu.tsx +++ b/app/components/HeaderMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useSession, notifyAuthChanged } from "~/lib/useSession"; import { useTheme, type Theme } from "~/lib/useTheme"; import Icon from "~/components/Icon"; +import Avatar from "~/components/Avatar"; declare global { interface Window { @@ -19,7 +20,7 @@ declare global { const themeOptions: { value: Theme; icon: string; label: string }[] = [ { value: "light", icon: "light_mode", label: "Light" }, { value: "dark", icon: "dark_mode", label: "Dark" }, - { value: "auto", icon: "brightness_auto", label: "Auto" }, + { value: "auto", icon: "computer", label: "Auto" }, ]; /** @@ -88,10 +89,16 @@ export default function HeaderMenu({ onOpenAgents }: { onOpenAgents: () => void aria-label="Menu" className="flex h-full shrink-0 cursor-pointer items-center px-3 transition-colors hover:bg-border" > - {session?.signedIn && session.avatar ? ( - + {session?.signedIn ? ( + ) : ( - + + + )} {open && ( @@ -110,7 +117,11 @@ export default function HeaderMenu({ onOpenAgents }: { onOpenAgents: () => void {session?.signedIn ? (
- {session.avatar && } + {session.displayName} -
+ start editing + start editing + + ); } diff --git a/app/components/ShareButton.tsx b/app/components/ShareButton.tsx index 939b3116..43a8a5c3 100644 --- a/app/components/ShareButton.tsx +++ b/app/components/ShareButton.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { serializeThreads } from "~/lib/thread-serialization"; import { useDocument } from "~/lib/DocumentContext"; import { Menu, MenuTrigger, MenuContent, MenuItem } from "~/components/ui/menu"; +import Icon from "~/components/Icon"; export default function ShareButton() { const { docId, markdown, threads } = useDocument(); @@ -35,8 +36,14 @@ export default function ShareButton() { - {copied ? "✓ Copied" : "Copy link"} - Download + + + {copied ? "Copied" : "Copy link"} + + + + Download +
); diff --git a/app/components/ThemeSelector.tsx b/app/components/ThemeSelector.tsx index 2ed8a624..95934141 100644 --- a/app/components/ThemeSelector.tsx +++ b/app/components/ThemeSelector.tsx @@ -6,7 +6,7 @@ import Icon from "~/components/Icon"; const options: { value: Theme; icon: string; label: string }[] = [ { value: "light", icon: "light_mode", label: "Light" }, { value: "dark", icon: "dark_mode", label: "Dark" }, - { value: "auto", icon: "brightness_auto", label: "Auto" }, + { value: "auto", icon: "computer", label: "Auto" }, ]; function ChevronDown() { @@ -32,7 +32,7 @@ export default function ThemeSelector() { className="flex cursor-pointer items-center gap-0.5 px-3 text-muted transition-colors hover:text-ink" aria-label="Theme" > - + ); diff --git a/app/components/ThreadPanel.tsx b/app/components/ThreadPanel.tsx index e7e2da57..37739b1e 100644 --- a/app/components/ThreadPanel.tsx +++ b/app/components/ThreadPanel.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import type { ThreadData } from "~/shared/types"; import Icon from "~/components/Icon"; +import Avatar from "~/components/Avatar"; function timeAgo(ts: number): string { const seconds = Math.floor((Date.now() - ts) / 1000); @@ -24,18 +25,12 @@ function AuthorHeader({ }) { return (
- {author.avatar ? ( - - ) : author.animal ? ( - - {author.animal} - - ) : ( - - )} +
{author.name} {timeAgo(timestamp)} @@ -95,13 +90,15 @@ export default function ThreadPanel({ return (
onSelect(active ? null : thread.id)} > {/* Author + timestamp + actions */}
e.stopPropagation()} >

{APP_NAME}

- Share and edit Markdown together, quickly + Live Markdown for people and agents, side by side

-
+ +

Or send your agent

+ +

+ Claude and friends join over MCP and edit with a visible cursor + — a place to iterate in the browser instead of the scrollback. +