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/.gitignore b/.gitignore index bf624a96..7218776b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ worker-configuration.d.ts # Coverage /coverage/ +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 6876b336..a8f3cf81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,21 +1,34 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This repo follows the Artifact Primer: https://github.com/arfct/ops/tree/main/primer -## Start of Session +- Standards (style, commits, branches): https://github.com/arfct/ops/blob/main/primer/standards.md +- Work tracking: Linear workspace `arfct`, team Artifact (A) — https://github.com/arfct/ops/blob/main/primer/linear.md +- Bugs: https://github.com/arfct/ops/blob/main/primer/bugs.md · Deployment: https://github.com/arfct/ops/blob/main/primer/deployment.md +- Agent conventions and boundaries: https://github.com/arfct/ops/blob/main/primer/agents.md + +## This repo + +vapor is a collaborative markdown editor — a fork of [mist](https://github.com/inanimate-tech/mist), deployed at https://vapor.fyi. `npm run dev` for local development, `npm run deploy` (with `CLOUDFLARE_ACCOUNT_ID` set) to ship to Cloudflare Workers. This is a fork: keep upstream's build tooling (ESLint config, CI) unchanged unless upstream changes it — don't propose tooling swaps here. + +### Start of Session 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 (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 +### 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. 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). -MIST 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. +Naming is "vapor" throughout: `APP_NAME`, page titles, the export frontmatter key (`vapor:`), and the theme localStorage key (`vapor-theme`). -## Tech Stack +### Tech Stack - **Backend:** Cloudflare Workers + Durable Objects (SQLite storage) - **Frontend:** React Router 7 (SSR) + Cloudflare Agents SDK @@ -24,7 +37,7 @@ MIST is a collaborative markdown editor — a cross between GitHub Gist and Goog - **Language:** TypeScript (strict mode) - **Testing:** Vitest with v8 coverage -## Prerequisites +### Prerequisites Requires Node.js 22+ (see `.nvmrc`). Before running commands: @@ -32,7 +45,7 @@ Requires Node.js 22+ (see `.nvmrc`). Before running commands: source ~/.nvm/nvm.sh && nvm use ``` -## Commands +### Commands ```bash npm run dev # Local development server @@ -51,29 +64,48 @@ npx vitest run tests/unit/lib/critic-parser.test.ts npx vitest run -t "pattern" ``` -## Architecture +### Architecture See `docs/technical-architecture.md` for full details. -### Directory Layout +#### Directory Layout -- `agents/` — Server-side Durable Object agents (currently just `DocumentAgent`) +- `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 -- `app/routes/` — File-based routing (`home.tsx`, `docs.$id.tsx`, `new.ts`) +- `app/routes/` — File-based routing (`home.tsx`, `doc.$id.tsx`, `new.ts`) - `workers/app.ts` — Cloudflare Worker entry point +- `workers/routes.ts` — Pure handlers for `/:id.md` and the `/mcp` help page - `tests/` — Unit tests (`tests/unit/`) and integration tests (`tests/integration/`) -### Import Path Alias +#### Routes + +Documents render at the root path, not under `/docs`: + +| Route | Handler | +|---|---| +| `/` | `home.tsx` | +| `/new` | `new.ts` | +| `/:id` | `doc.$id.tsx` | +| `/:id.md` | `workers/routes.ts` — raw markdown export | +| `/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. + +#### Import Path Alias `~` resolves to `app/` (configured in tsconfig and vitest). Use `~/lib/foo` instead of relative paths. -### Critical Rule: Server/Client Separation +#### Critical Rule: Server/Client Separation Client-side React components must **never** import from `agents/`. The `agents` package uses `cloudflare:` protocol imports that don't exist in the browser. Use `app/shared/` for types needed by both sides. -### Real-Time Collaboration Flow +#### Real-Time Collaboration Flow The multiplayer system works as follows: @@ -82,7 +114,7 @@ The multiplayer system works as follows: 3. **TipTap** uses `@tiptap/extension-collaboration` (bound to the Yjs doc's `XmlFragment`) and `@tiptap/extension-collaboration-caret` for cursor awareness. 4. **Worker entry** (`workers/app.ts`) — `routeAgentRequest()` intercepts `/agents/:agent/:name` requests before React Router handles the rest. -### CriticMarkup / Suggest Mode +#### CriticMarkup / Suggest Mode Track-changes functionality spans multiple files: @@ -92,13 +124,30 @@ Track-changes functionality spans multiple files: - `app/lib/critic-serializer.ts` — Serializes marks back to CriticMarkup delimiter syntax - `app/lib/critic-markup.ts` — TipTap extension that wires up the CriticMarkup marks and delimiter decorations -### Testing Constraints +#### Agent collaborators + +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 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 - The `agents` package uses `cloudflare:` imports — it **cannot** be imported in plain Vitest. Test agent logic through integration tests or mock the imports. Unit tests should focus on pure logic in `app/lib/` and `app/shared/`. - Coverage thresholds ramp linearly from 0% to 80% between Feb–Dec 2026 (see `vitest.config.ts`). - Tests live in `tests/unit/` and `tests/integration/`, mirroring the source structure. -### ESLint Conventions +#### ESLint Conventions - Unused variables must be prefixed with `_` (e.g., `_args`, `_ctx`). - Tagged template expressions are allowed (for `this.sql` in Durable Objects). diff --git a/README.md b/README.md index c825e9a6..859bb87f 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,67 @@ -# mist +# vapor -Collaborative markdown editor. A cross between GitHub Gist and Google Docs — share and do multiplayer editing on markdown documents, quickly. +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. -Everything is public by URL. Documents persist live with no save button. Multiple users see each other's cursors in real time. +Running at [vapor.fyi](https://vapor.fyi). A fork of [mist](https://github.com/inanimate-tech/mist). -## Features +## Documents -- **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 -- **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 +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. -## Tech stack - -- [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) -- [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 +```bash +curl https://vapor.fyi/new -T notes.md # create from a file +curl https://vapor.fyi/.md # raw markdown back +``` -## Getting started +## People and agents -### Prerequisites +Sign-in (Google) is optional and only changes attribution: -- Node.js 22+ (see `.nvmrc`) -- A Cloudflare account (free tier works) +| | Human | Agent | +|---|---|---| +| Anonymous | Curious Ladybug 🐞 | Agentic Butterfly 🦋 | +| Signed in | Ada Lovelace | Ada's Agent | -### Setup +Your anonymous animal lives in localStorage and follows you between documents. Sign in and your name takes over, earlier comments included. -```bash -git clone https://github.com/inanimate-tech/mist.git -cd mist -npm install -``` +## Connecting an agent -### Development +vapor is an [MCP](https://modelcontextprotocol.io) server. Two ways in: ```bash -npm run dev -``` +# signed in: stable identity, write access if you grant it +claude mcp add --transport http vapor https://vapor.fyi/mcp -### Deploy +# anonymous: no setup, suggest and comment only +claude mcp add --transport http vapor https://vapor.fyi/mcp/anonymous +``` -Set your Cloudflare account ID via environment variable: +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`. -```bash -export CLOUDFLARE_ACCOUNT_ID=your-account-id -npm run deploy -``` +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. -### Optional: Analytics +## How it's built -To enable [Fathom](https://usefathom.com/) analytics, set these environment variables (or add to `.dev.vars`): +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). ``` -VITE_FATHOM_SITE_ID=your-site-id -VITE_FATHOM_DOMAINS=your-domain.com +agents/ Durable Objects: DocumentAgent, VaporMcp, Registry +app/ React Router app +workers/ Worker entry, routes, OAuth server +tests/ Unit + integration ``` -### Commands - -```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 -``` +## Developing -## Project structure +Node 22+. -``` -agents/ Durable Object agents (server-side document state) -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 +```bash +npm install +npm run dev # local server +npm run test # also: typecheck, lint +npm run deploy # needs CLOUDFLARE_ACCOUNT_ID ``` -## Licence +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/). -[MIT](LICENSE) +[Privacy](https://vapor.fyi/privacy) · [Terms](https://vapor.fyi/terms) · [MIT](LICENSE) diff --git a/agents/document.ts b/agents/document.ts index 80cc9538..df85291e 100644 --- a/agents/document.ts +++ b/agents/document.ts @@ -5,7 +5,68 @@ 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 { AgentIdentity, AgentCapability, AgentRosterEntry, AgentError, Pace } from "../app/shared/agent-protocol"; +import { + AGENT_NAME_RE, + findMentions, + MAX_AGENTS_PER_DOC, + RATE_LIMIT_MUTATIONS_PER_MIN, + RATE_LIMIT_CHARS_PER_HOUR, +} from "../app/shared/agent-protocol"; +import { + getBlocks, + yDocToMarkdown, + resolveAnchor, + buildMarkdownBlocks, + insertBlockNodes, + deleteBlocks, + formatAnchor, + parseMarkdown, + buildTypedBlock, + 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"; + +/** 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; + +/** + * Wall-clock budget for one typed performance. Typing pins this Durable + * Object in memory for its whole duration (a real cost — see + * docs/plans/2026-08-31-sleeping-tabs-plan.md), so past the budget the + * remainder of the mutation applies instantly instead of continuing the + * show. + */ +const PERFORMANCE_WALL_BUDGET_MS = 10_000; /** * Durable Objects SQLite accepts Uint8Array for BLOB columns via the @@ -16,10 +77,155 @@ function sqlBlob(data: Uint8Array): string { return data as unknown as string; } +/** + * All Y.XmlText descendants of a block element, in document order — rich + * blocks (lists, quotes) nest their text inside child elements. A suggest's + * `find` must land inside a single text node; matches that span nodes are + * treated as not found. + */ +function textNodesUnder(el: Y.XmlElement): Y.XmlText[] { + const out: Y.XmlText[] = []; + for (const child of el.toArray()) { + if (child instanceof Y.XmlText) out.push(child); + else if (child instanceof Y.XmlElement) out.push(...textNodesUnder(child)); + } + return out; +} + +function findInBlock( + el: Y.XmlElement, + find: string, +): { ytext: Y.XmlText; pos: number } | null { + for (const ytext of textNodesUnder(el)) { + const text = (ytext.toDelta() as { insert: string }[]).map((op) => op.insert).join(""); + const pos = text.indexOf(find); + if (pos !== -1) return { ytext, pos }; + } + return null; +} + +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 RosterRow { + identity_id: string; + name: string; + label?: string | null; + color: string; + owner: string | null; + 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: RosterRow): AgentRosterEntry { + return { + name: row.name, + label: row.label ?? null, + 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; + /** 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; + + /** + * 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>(); + + /** 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; + /** Agent names already notified for a block's text node — see notifyMentions. */ + private notifiedMentions = new WeakMap>(); + + private persistTimer: ReturnType | null = null; + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.flushDocState(); + }, 1_000); + } + + private flushDocState(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + if (!this.doc) return; + const state = Y.encodeStateAsUpdate(this.doc); + this.sql` + INSERT INTO doc_state (key, value) VALUES ('state', ${sqlBlob(state)}) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `; + } + private ensureInitialised(): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { if (this.doc && this.awareness) { return { doc: this.doc, awareness: this.awareness }; @@ -28,13 +234,58 @@ 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 roster ( + identity_id TEXT PRIMARY KEY, + name TEXT UNIQUE, + label TEXT, + color TEXT, + owner TEXT, + capabilities TEXT, + created_at INTEGER, + last_seen_at INTEGER, + 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 + ) + `; + this.sql` + CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT, + payload TEXT, + 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 }>` @@ -46,13 +297,129 @@ class DocumentAgent extends Agent { Y.applyUpdate(this.doc, state); } - // Persist on every update - this.doc.on("update", () => { - const state = Y.encodeStateAsUpdate(this.doc!); - this.sql` - INSERT INTO doc_state (key, value) VALUES ('state', ${sqlBlob(state)}) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `; + // Persist on a quiet edge, not per update: a typing burst is one write + // instead of hundreds (rows-written quota, full-state encode CPU, and + // the pending timer only pins the DO for the debounce window). The + // ≤1s crash-loss window is healed on reconnect by any client's state + // vector sync. Explicit flushes: last connection closing, and document + // creation (so a freshly imported doc survives immediate eviction). + this.doc.on("update", (update: Uint8Array, origin: unknown) => { + this.schedulePersist(); + + // Agent-originated mutations (doc.transact(fn, "agent")) never pass + // through onMessage's relay — they mutate this DO's Y.Doc directly — + // so without this, connected browsers never see them until their next + // reconnect replays full state. Human-origin updates are already + // relayed by onMessage's broadcastBinary of the raw incoming sync + // message, so broadcasting them again here would double-send. + if (origin === "agent") { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MSG_SYNC); + syncProtocol.writeUpdate(encoder, update); + this.broadcastToAll(encoding.toUint8Array(encoder)); + } + }); + + // 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 + `; + // A poisoned row (unparseable payload, or one whose application throws) + // must not take the rest of initialisation down with it: the observers + // below would never be registered, permanently disabling mentions and + // events, and the surviving row would later collide with a + // nextPerformanceId that restarts at 1. Log it and drop it. + for (const row of leftover) { + try { + const mutation = JSON.parse(row.payload) as MutationPayload; + this.applyMutation(mutation); + } catch (err) { + console.error(`Dropping unrecoverable performance row ${row.id}:`, err); + } + 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; + + // No agents on the roster means nothing consumes events — not + // mentions, and not doc_changed digests either. Check first, so an + // agentless document accrues no `events` rows at all. + const rosterNames = this.getRosterNamesSync(); + if (rosterNames.length === 0) return; + + const now = Date.now(); + if (now - this.lastDigestAt >= 30_000) { + this.lastDigestAt = now; + this.recordEvent("doc_changed", {}); + } + + // Scan each touched block's *full* text, not the individual delta ops: + // a human typing "@scribe" delivers one op per keystroke, and no single + // character ever matches the mention pattern. Only pasting did. + const scanned = new Set(); + for (const event of events) { + const target = event.target; + if (!(target instanceof Y.XmlText)) continue; + if (scanned.has(target)) continue; + scanned.add(target); + const text = this.findBlockTextForXmlText(target); + if (text === null) continue; // block already gone from the fragment + this.notifyMentions(target, text, rosterNames); + } + }); + + // 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; + + 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 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 }); + } }); return { doc: this.doc, awareness: this.awareness }; @@ -83,6 +450,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) { @@ -146,11 +520,51 @@ class DocumentAgent extends Agent { null, ); } + + // Last human gone: flush any pending persistence and drop every + // standing timer so nothing keeps the DO pinned in memory — agent + // presence only matters while someone is watching, and it rebuilds + // from the roster on the next performance anyway. + if (!this.hasHumanConnections()) { + this.flushDocState(); + for (const timer of this.agentIdleTimers.values()) { + clearTimeout(timer); + } + this.agentIdleTimers.clear(); + this.agentPresence.clear(); + } } override readonly alarm = async (): Promise => { + // A pending persist must not resurrect state after the delete below. + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } // Auto-delete: remove all document data this.sql`DELETE FROM doc_state`; + // 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 = []; + 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`; + // 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 + // 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"); @@ -189,52 +603,54 @@ class DocumentAgent extends Agent { if (contentType.includes("application/json")) { try { const body = await request.json() as { content?: string; threads?: unknown[]; onboarding?: boolean }; + + // Parse before the transaction: Yjs cannot roll back, and a parse + // failure must not commit a half-imported document. + let importNodes: Y.XmlElement[] | null = null; 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); - } - para.insert(0, [ytext]); - frag.insert(frag.length, [para]); - } + const built = buildMarkdownBlocks(body.content); + if (!built.ok) { + return new Response(JSON.stringify({ ok: false, error: built.message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } + importNodes = built.nodes; } - 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)); + + // 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 (importNodes) { + const frag = doc.getXmlFragment("default"); + if (frag.length === 0) { + frag.insert(0, importNodes); } } - } - if (body.onboarding) { - const docState = doc.getMap("docState"); - docState.set("onboarding", "true"); - } - } catch (err) { - // If it's an unsupported CriticMarkup error, return it - if (err instanceof Error && err.message.includes("Unsupported CriticMarkup")) { - return new Response(JSON.stringify({ ok: false, error: err.message }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - // Ignore other malformed JSON — document is still created + 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"); + } + }, "agent"); + } catch { + // Ignore malformed JSON — document is still created } } + // Persist immediately: a freshly created document must survive an + // eviction that lands inside the debounce window. + this.flushDocState(); + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); @@ -243,10 +659,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 +677,1484 @@ 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; + } + + /** + * 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). + */ + private ensureRosterEntry( + identity: AgentIdentity, + ): { entry: AgentRosterEntry } | { error: AgentError } { + this.ensureInitialised(); + + if (!this.docExists()) { + return { error: { code: "doc_not_found", message: "Document does not exist" } }; + } + + const existing = this.sql` + SELECT * FROM roster WHERE identity_id = ${identity.id} + `; + if (existing.length > 0) { + 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; + } + 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) }; + } + + 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 first.`, + }, + }; + } + + 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 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, + createdAt, + lastSeenAt: null, + }, + }; + } + + /** Lists all agents minted for this document, oldest first. */ + async getAgentRoster(): Promise { + this.ensureInitialised(); + const rows = this.sql` + SELECT * FROM roster ORDER BY created_at ASC + `; + 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 roster`; + 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 frag = this.doc.getXmlFragment("default"); + // Climb to the top-level block containing this text node — rich blocks + // (lists, quotes) nest text several elements deep. + let node: unknown = ytext; + while (node && (node as { parent: unknown }).parent !== frag) { + node = (node as { parent: unknown }).parent; + } + if (!(node instanceof Y.XmlElement)) return null; + const index = frag.toArray().indexOf(node); + if (index === -1) return null; + return getBlocks(this.doc)[index]?.text ?? null; + } + + /** + * Records a "mention" event for every roster agent named in a block's text + * that hasn't already been notified about this block. + * + * De-duplication is per (block text node, agent name), because the scan + * runs over the block's whole text on every keystroke in it — without this, + * "@scribe, could you..." would fire a fresh mention for every character + * typed after the name. A name is forgotten again as soon as it is no + * longer present in the block, so deleting the mention and retyping it + * notifies properly rather than being swallowed. The map is keyed weakly by + * the live Y.XmlText node, so it needs no explicit clearing: entries go + * away with the blocks (and with the whole document on expiry). + */ + private notifyMentions(ytext: Y.XmlText, text: string, rosterNames: string[]): void { + const mentioned = new Set(findMentions(text, rosterNames)); + + let notified = this.notifiedMentions.get(ytext); + if (!notified) { + notified = new Set(); + this.notifiedMentions.set(ytext, notified); + } + + for (const name of notified) { + if (!mentioned.has(name)) notified.delete(name); + } + + for (const name of mentioned) { + if (notified.has(name)) continue; + notified.add(name); + this.recordEvent("mention", { agent: name, text }); + } + } + + /** + * 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()}) + `; + // 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(); + } + + /** + * 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 15s) elapses, then + * returns whatever is available at that point (possibly still empty — + * then with a `retryAfterMs` pacing hint). Only a valid token is + * required — read is implied. + */ + async agentAwaitEvents( + identity: AgentIdentity, + args: { cursor?: number; timeoutMs?: number }, + ): Promise< + | { events: { seq: number; type: DocEventType; payload: unknown }[]; cursor: number; retryAfterMs?: number } + | { error: AgentError } + > { + const verified = await this.verifyIdentity(identity); + if ("error" in verified) return verified; + + const cursor = args.cursor ?? 0; + const self = verified.entry.name; + + /** + * Reads events past the cursor, keeping only those addressed to this + * agent. "mention" and "thread_reply" name their target agent in the + * payload and are nobody else's business; "doc_changed" is a broadcast + * digest and goes to everyone. + * + * `lastSeq` is the highest row *scanned*, not the highest returned, so an + * agent's cursor still advances past events filtered out for it — it + * never re-scans another agent's notifications. + */ + const readPast = (): { + events: { seq: number; type: DocEventType; payload: unknown }[]; + lastSeq: number; + } => { + const rows = this.sql` + SELECT * FROM events WHERE seq > ${cursor} ORDER BY seq ASC + `; + const out: { seq: number; type: DocEventType; payload: unknown }[] = []; + let lastSeq = cursor; + for (const row of rows) { + lastSeq = Math.max(lastSeq, row.seq); + let payload: unknown; + try { + payload = JSON.parse(row.payload) as unknown; + } catch { + console.warn(`Skipping unparseable event ${row.seq}`); + continue; + } + const type = row.type as DocEventType; + if (type === "mention" || type === "thread_reply") { + if ((payload as { agent?: string }).agent !== self) continue; + } + out.push({ seq: row.seq, type, payload }); + } + return { events: out, lastSeq }; + }; + + let { events, lastSeq } = readPast(); + if (events.length === 0) { + // Capped at 15s: an in-flight RPC pins this Durable Object in memory + // for its whole duration, and every idle long-polling agent used to + // be a full-time pinned DO (see the sleeping-tabs plan). The + // retryAfterMs hint below asks quiet agents to poll on a cadence. + const timeoutMs = Math.min(args.timeoutMs ?? 15_000, 15_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, lastSeq } = readPast()); + } + + if (events.length === 0) { + return { events, cursor: lastSeq, retryAfterMs: 30_000 }; + } + 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(); + this.sql`DELETE FROM roster WHERE name = ${name}`; + this.clearAgentIdleTimer(name); + this.setAgentPresence(name, null); + return { ok: true }; + } + + /** + * 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 verifyIdentity( + identity: AgentIdentity, + needs?: AgentCapability, + ): Promise<{ entry: AgentRosterEntry } | { error: AgentError }> { + 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 enrolled = this.ensureRosterEntry(identity); + if ("error" in enrolled) return enrolled; + + // 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 roster SET last_seen_at = ${now} WHERE identity_id = ${identity.id}`; + + if (needs && !identity.caps.includes(needs)) { + return { + error: { code: "capability_denied", message: `Agent lacks capability: ${needs}` }, + }; + } + + return { entry: { ...enrolled.entry, lastSeenAt: 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(identityId: string, chars: number): Promise<{ error: AgentError } | null> { + const rows = this.sql<{ recent_mutations: string | null }>` + SELECT recent_mutations FROM roster WHERE identity_id = ${identityId} + `; + + const now = Date.now(); + const hourAgo = now - 60 * 60 * 1000; + const minuteAgo = now - 60 * 1000; + + const raw = rows[0]?.recent_mutations; + // A corrupt log is treated as empty rather than thrown from: it is + // rewritten (pruned) at the end of every check, so the column self-heals + // on this very call. + let log: MutationLogEntry[] = []; + if (raw) { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) log = parsed as MutationLogEntry[]; + } catch { + console.warn("Discarding unparseable rate-limit log for an agent token"); + } + } + 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 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 roster SET recent_mutations = ${JSON.stringify(pruned)} WHERE identity_id = ${identityId}`; + 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(identity: AgentIdentity): Promise< + | { + markdown: string; + blocks: { anchor: string; text: string }[]; + presence: { name: string; isAgent: boolean }[]; + threads: ThreadData[]; + } + | { error: AgentError } + > { + const verified = await this.verifyIdentity(identity); + 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.label ?? entry.name, isAgent: true }); + } + } + + const threadsMap = doc.getMap("threads"); + const threads: ThreadData[] = []; + threadsMap.forEach((value, key) => { + // Thread JSON is written by clients into a shared Y.Map, so a single + // malformed entry must not take the whole read down with it — the rest + // of the document is still perfectly readable without it. + try { + threads.push(JSON.parse(value) as ThreadData); + } catch { + console.warn(`Skipping unparseable thread ${key} in agentRead`); + } + }); + + 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 + * before or after it. Requires `write`. + */ + async agentInsert( + identity: AgentIdentity, + args: { anchor?: string; where: "before" | "after" | "append"; markdown: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity, "write"); + if ("error" in verified) return verified; + + // Validate before charging the rate limit — a malformed call shouldn't + // spend the agent's budget. + if (args.where !== "append" && !args.anchor) { + return { + error: { code: "stale_anchor", message: `An anchor is required for where: "${args.where}"` }, + }; + } + + const rateLimited = await this.checkRateLimit(identity.id, args.markdown.length); + if (rateLimited) return rateLimited; + + return this.dispatchMutation(verified.entry.name, args.pace, { + kind: "insert", + anchor: args.anchor, + where: args.where, + markdown: args.markdown, + }); + } + + /** + * Replaces the block range [from, to] (anchors, `to` defaults to `from`) + * with new markdown, in one transaction. Requires `write`. + */ + async agentReplace( + identity: AgentIdentity, + args: { from: string; to?: string; markdown: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity, "write"); + if ("error" in verified) return verified; + + // Validate before charging the rate limit — see agentInsert. + if (!args.from) { + return { + error: { code: "stale_anchor", message: 'A "from" anchor is required for replace' }, + }; + } + + const rateLimited = await this.checkRateLimit(identity.id, args.markdown.length); + if (rateLimited) return rateLimited; + + return this.dispatchMutation(verified.entry.name, args.pace, { + kind: "replace", + from: args.from, + to: args.to, + markdown: args.markdown, + }); + } + + /** + * 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( + identity: AgentIdentity, + args: { anchor: string; find: string; replacement: string; pace?: Pace }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity, "suggest"); + if ("error" in verified) return verified; + + 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, { + kind: "suggest", + anchor: args.anchor, + find: args.find, + replacement: args.replacement, + }); + } + + /** + * 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( + identity: AgentIdentity, + args: { anchor: string; quote?: string; text: string }, + ): Promise<{ threadId: string } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity, "comment"); + if ("error" in verified) return verified; + + const rateLimited = await this.checkRateLimit(identity.id, args.text.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 { name, label, color } = verified.entry; + const id = crypto.randomUUID(); + const thread: ThreadData = { + id, + commentText: args.text, + highlightText: args.quote, + author: { name: label ?? 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 `thread_not_found`. + */ + async agentReply( + identity: AgentIdentity, + args: { threadId: string; text: string }, + ): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity, "comment"); + if ("error" in verified) return verified; + + const rateLimited = await this.checkRateLimit(identity.id, 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: "thread_not_found", message: "thread not found" } }; + } + + // An unparseable thread is no more replyable than a missing one — same + // typed error, rather than a throw through the RPC. + let thread: ThreadData; + try { + thread = JSON.parse(raw) as ThreadData; + } catch { + return { error: { code: "thread_not_found", message: "thread is unreadable" } }; + } + if (!Array.isArray(thread?.replies)) { + return { error: { code: "thread_not_found", message: "thread is unreadable" } }; + } + + const { name, label, color } = verified.entry; + const reply: ThreadReply = { + id: crypto.randomUUID(), + author: { name: label ?? 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 + * idle timer. Any valid token may join — presence is not a capability. + */ + 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, label, color } = verified.entry; + this.setAgentPresence(name, { + user: { name: label ?? 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(identity: AgentIdentity): Promise<{ ok: true } | { error: AgentError }> { + const verified = await this.verifyIdentity(identity); + 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 + * 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 } { + // Reject markdown the schema can't represent here, before the + // instant/queued fork: an agent gets the same typed error whatever its + // pace, and nothing unparseable is ever persisted to `performances`. + if (mutation.kind === "insert" || mutation.kind === "replace") { + const parsed = parseMarkdown(mutation.markdown); + if (!parsed.ok) { + return { error: { code: "unsupported_markup", message: parsed.message } }; + } + } + + 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) { + // runPerformances swallows per-mutation failures itself; the catch is + // the last line of defence against an unhandled rejection here. + void this.runPerformances().catch((err) => { + console.error("Performance runner failed:", err); + }); + } + + return { ok: true }; + } + + /** + * 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". + * + * Nothing here may escape as a throw. `isPerforming` is cleared in a + * `finally` (leaking it `true` would wedge the queue forever, since + * enqueuePerformance only starts a runner when it is false), and each + * mutation is attempted inside its own try/catch so one poisoned item is + * dropped — row and all — instead of stalling everything behind it. + */ + private async runPerformances(): Promise { + this.isPerforming = true; + try { + while (this.performanceQueue.length > 0) { + const item = this.performanceQueue[0]; + try { + await this.performQueuedMutation(item); + } catch (err) { + console.error(`Dropping failed performance ${item.id}:`, err); + this.deletePerformanceRow(item.id); + } + this.performanceQueue.shift(); + } + } finally { + 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 + * 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); + this.deletePerformanceRow(item.id); + return; + } + if (item.mutation.kind === "insert") { + await this.performTypedInsert(item, pace); + return; + } + 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. + * + * Multi-paragraph markdown applies as one shot once it's this mutation's + * 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(item: PendingMutation, pace: "natural" | "fast"): Promise { + const mutation = item.mutation as Extract; + const { doc } = this.ensureInitialised(); + + let index: number; + if (mutation.where === "append") { + index = doc.getXmlFragment("default").length; + } else { + if (!mutation.anchor) { + this.deletePerformanceRow(item.id); + return; + } + const resolved = resolveAnchor(doc, mutation.anchor); + if ("error" in resolved) { + this.deletePerformanceRow(item.id); + return; + } + index = mutation.where === "before" ? resolved.index : resolved.index + 1; + } + + const parsed = parseMarkdown(mutation.markdown); + if (!parsed.ok) { + // Unreachable via the RPCs (dispatchMutation validates before + // queueing) — this is the poisoned-row backstop. + console.warn(`Dropping queued insert with unsupported markup: ${parsed.message}`); + this.deletePerformanceRow(item.id); + return; + } + + // Type block by block. Each block's skeleton (structure with empty text + // nodes) is inserted synchronously — claiming its slot before any await + // can let a concurrent mutation shift indices — and its text is then + // typed run by run WITH the run's formatting attributes, so styled text + // styles as it appears. The `performances` row is deleted at the first + // claim (eviction from then on loses only the untyped tail; see the + // original doc comment above). + const frag = doc.getXmlFragment("default"); + const deadline = Date.now() + PERFORMANCE_WALL_BUDGET_MS; + let rowDeleted = false; + let prevElement: Y.XmlElement | null = null; + + // Budget cutover: applies everything not yet typed in a handful of + // transactions. Later runs/fills append at the end of their (still + // agent-owned) text nodes; whole untouched blocks insert as complete + // elements after the last block we placed. + const finishInstantly = ( + fills: { ytext: Y.XmlText; runs: { text: string; attrs?: Record }[] }[], + fillIdx: number, + runIdx: number, + typedInRun: number, + nextBlock: number, + ): void => { + doc.transact(() => { + for (let f = fillIdx; f < fills.length; f++) { + const fill = fills[f]; + const startRun = f === fillIdx ? runIdx : 0; + for (let r = startRun; r < fill.runs.length; r++) { + const run = fill.runs[r]; + const text = f === fillIdx && r === runIdx ? run.text.slice(typedInRun) : run.text; + if (!text) continue; + fill.ytext.insert(fill.ytext.length, text, run.attrs as Record); + } + } + for (let b = nextBlock; b < parsed.doc.childCount; b++) { + const el = pmNodeToYElement(parsed.doc.child(b)); + if (prevElement) { + const prevIndex = frag.toArray().indexOf(prevElement); + if (prevIndex === -1) return; + frag.insert(prevIndex + 1, [el]); + } else { + frag.insert(Math.min(index, frag.length), [el]); + } + prevElement = el; + } + }, "agent"); + }; + + for (let b = 0; b < parsed.doc.childCount; b++) { + const { element, fills } = buildTypedBlock(parsed.doc.child(b)); + + // Re-derive the insertion point from the previous typed block: its + // index is only trustworthy while we haven't yielded. + let insertAt: number; + if (prevElement) { + const prevIndex = frag.toArray().indexOf(prevElement); + if (prevIndex === -1) return; // our earlier work was deleted — stop + insertAt = prevIndex + 1; + } else { + insertAt = Math.min(index, frag.length); + } + + doc.transact(() => frag.insert(insertAt, [element]), "agent"); + if (!rowDeleted) { + this.deletePerformanceRow(item.id); + rowDeleted = true; + } + prevElement = element; + + for (let fillIdx = 0; fillIdx < fills.length; fillIdx++) { + const fill = fills[fillIdx]; + let relPos = Y.createRelativePositionFromTypeIndex(fill.ytext, 0); + for (let runIdx = 0; runIdx < fill.runs.length; runIdx++) { + const run = fill.runs[runIdx]; + const ticks = chunkTyping(run.text, pace); + let typedInRun = 0; + for (const tick of ticks) { + if (Date.now() > deadline) { + finishInstantly(fills, fillIdx, runIdx, typedInRun, b + 1); + return; + } + await sleep(tick.delayMs); + const { doc: liveDoc } = this.ensureInitialised(); + const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, liveDoc); + if (!absPos || absPos.type !== fill.ytext) { + // The block (or its text) is gone — nothing sane left to type into. + return; + } + doc.transact( + () => fill.ytext.insert(absPos.index, tick.chunk, run.attrs as Record), + "agent", + ); + typedInRun += tick.chunk.length; + const caretOffset = absPos.index + tick.chunk.length; + relPos = Y.createRelativePositionFromTypeIndex(fill.ytext, caretOffset); + this.onPerformanceCursor(item.agentName, fill.ytext, caretOffset); + } + } + } + } + } + + /** + * 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(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) { + this.deletePerformanceRow(item.id); + return; + } + + const frag = doc.getXmlFragment("default"); + const el = frag.get(resolved.index); + const match = el instanceof Y.XmlElement ? findInBlock(el, mutation.find) : null; + if (!match) { + this.deletePerformanceRow(item.id); + return; + } + const { ytext, pos } = match; + + // Claim the slot now, synchronously — see the doc comment above. + doc.transact(() => ytext.format(pos, mutation.find.length, { criticDeletion: {} }), "agent"); + let relPos = Y.createRelativePositionFromTypeIndex(ytext, pos + mutation.find.length); + this.deletePerformanceRow(item.id); + + const deadline = Date.now() + PERFORMANCE_WALL_BUDGET_MS; + const ticks = chunkTyping(mutation.replacement, pace); + let typed = 0; + const resolve = (): number | null => { + const { doc: liveDoc } = this.ensureInitialised(); + const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, liveDoc); + // Null when the block (or its text) is gone — nothing sane to type into. + return absPos && absPos.type === ytext ? absPos.index : null; + }; + + for (const tick of ticks) { + if (Date.now() > deadline) { + // Budget spent — land the rest of the replacement in one shot. + const at = resolve(); + if (at === null) return; + const rest = mutation.replacement.slice(typed); + doc.transact(() => ytext.insert(at, rest, { criticAddition: {} }), "agent"); + return; + } + await sleep(tick.delayMs); + const at = resolve(); + if (at === null) return; + doc.transact(() => ytext.insert(at, tick.chunk, { criticAddition: {} }), "agent"); + typed += tick.chunk.length; + const caretOffset = at + tick.chunk.length; + relPos = Y.createRelativePositionFromTypeIndex(ytext, caretOffset); + this.onPerformanceCursor(item.agentName, ytext, caretOffset); + } + } + + /** + * 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, 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; 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].label ?? rows[0].name, color: rows[0].color, isAgent: true }; + } + + this.setAgentPresence(agentName, { user, ...(status !== undefined ? { status } : {}), cursor }); + this.resetAgentIdleTimer(agentName); + } + + /** + * 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 = doc.getXmlFragment("default").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; + } + + // Parse before opening the transaction — see buildMarkdownBlocks. + const built = buildMarkdownBlocks(m.markdown); + if (!built.ok) { + return { error: { code: "unsupported_markup", message: built.message } }; + } + + doc.transact(() => { + insertBlockNodes(doc, index, built.nodes); + }, "agent"); + + 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, + }, + }; + } + + // Build the replacement paragraphs *before* the transaction opens. + // Yjs cannot roll a transaction back, so parsing inside it would let + // a parse failure commit the delete and lose the replaced blocks + // outright. + const built = buildMarkdownBlocks(m.markdown); + if (!built.ok) { + return { error: { code: "unsupported_markup", message: built.message } }; + } + + doc.transact(() => { + deleteBlocks(doc, fromIndex, toIndex); + insertBlockNodes(doc, fromIndex, built.nodes); + }, "agent"); + + 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 block = getBlocks(doc)[resolved.index]; + const match = el instanceof Y.XmlElement ? findInBlock(el, m.find) : null; + if (!match) { + return { + error: { code: "find_not_matched", message: "Could not find text to suggest a change on", snippet: block?.text ?? "" }, + }; + } + + doc.transact(() => { + match.ytext.format(match.pos, m.find.length, { criticDeletion: {} }); + match.ytext.insert(match.pos + m.find.length, m.replacement, { criticAddition: {} }); + }, "agent"); + + return { ok: true }; + } + } + } + + /** + * Sends a pre-encoded binary frame to every connected client, with no + * exclusion — used for server-originated broadcasts (agent mutations) + * where there is no originating connection to exclude, unlike + * broadcastBinary's relay of a message that arrived from one client. + */ + private broadcastToAll(bytes: Uint8Array) { + const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + for (const conn of this.getConnections()) { + conn.send(buf); + } + } + private broadcastBinary(message: WSMessage, excludeId: string) { // Make a clean copy to avoid ArrayBufferView offset issues const bytes = 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 new file mode 100644 index 00000000..fb8f2f70 --- /dev/null +++ b/agents/mcp-tools.ts @@ -0,0 +1,344 @@ +/** + * 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 verified identity. + */ +import { z } from "zod"; +import { isValidDocumentId } from "../app/shared/constants"; +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 { + 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; + 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 { + /** Resolves a document id to its DocumentAgent stub. */ + getStub(docId: string): Promise; + /** The verified identity of the caller (principal or anonymous session). */ + identity: AgentIdentity; +} + +/** 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 } }; +} + +/** Matches MAX_CONTENT_BYTES in app/routes/new.ts — the same document store. */ +const MAX_CONTENT_BYTES = 1_000_000; // 1 MB + +/** + * Guards for markdown handed to create_document, mirroring the checks POST + * /new applies to an uploaded file: a size ceiling, and a NUL-byte check that + * catches a binary file pasted in as if it were text. Lives here (rather than + * inline in agents/mcp.ts, which can't be imported in plain Vitest) so it can + * be tested directly. Returns null when the markdown is acceptable. + */ +export function validateNewDocumentMarkdown( + markdown: string | undefined, +): { error: AgentError } | null { + if (markdown === undefined) return null; + if (markdown.length > MAX_CONTENT_BYTES) { + return errorResult("rate_limited", "markdown too large (max 1MB)"); + } + if (markdown.includes("\0")) { + return errorResult("unsupported_markup", "content appears to be binary, not text"); + } + return null; +} + +/** + * The base agent name create_document enrolls its creator under, derived + * from the connecting MCP client's declared name — the same rule the + * 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. 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"); +} + +/** + * 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"]) + .optional() + .describe("How the edit is performed: natural (human-paced typing), fast, or instant."); +const anchorDesc = + "A block anchor from read_document, e.g. k3f0a9x2-a91f0c2d: a persistent block id plus the block's content hash. The id survives edits; a changed hash returns stale_block with the block's current state so you can retry without a full re-read."; + +/** + * 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, identity: AgentIdentity, 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.identity, 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, identity) => stub.agentRead(identity), + }), + + 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, identity, args) => + stub.agentInsert(identity, { + 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, identity, args) => + stub.agentReplace(identity, { + 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 PLAIN text within that block to replace — match against the block's rendered text, not its markdown syntax.", + ), + replacement: z.string().describe("The suggested replacement text (empty string to delete)."), + pace, + }, + call: (stub, identity, args) => + stub.agentSuggest(identity, { + 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, identity, args) => + stub.agentComment(identity, { + 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, identity, args) => + stub.agentReply(identity, { + 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, identity, args) => stub.agentJoin(identity, args.status as string | undefined), + }), + + docTool({ + name: "leave", + description: "Remove this agent's presence from the document.", + schema: {}, + call: (stub, identity) => stub.agentLeave(identity), + }), + + docTool({ + name: "await_events", + description: + "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() + .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, identity, args) => { + const timeoutS = args.timeout_s as number | undefined; + return stub.agentAwaitEvents(identity, { + cursor: args.since_cursor as number | undefined, + timeoutMs: timeoutS === undefined ? undefined : timeoutS * 1000, + }); + }, + }), + + 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 new file mode 100644 index 00000000..0c165140 --- /dev/null +++ b/agents/mcp.ts @@ -0,0 +1,291 @@ +/** + * The MCP server vapor exposes on two doors (routed in workers/app.ts): + * + * /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"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { + TOOLS, + validateNewDocumentMarkdown, + createDocumentAgentName, + 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, + 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 { + /** 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; +} + +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" }, + { + 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; + private agentLabel: 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"); + const { profile } = await registry.getProfile(auth.principal); + const ownerName = profile?.displayName ?? auth.email.split("@")[0] ?? "Someone"; + // First name only: "Ada's Agent", not the full display name. + const firstName = ownerName.trim().split(/\s+/)[0] || "Someone"; + this.agentLabel = `${firstName}'s Agent`; + } + return { + kind: "principal", + id: auth.principal, + name: this.agentSlug, + label: this.agentLabel ?? undefined, + owner: auth.principal, + caps: auth.caps ?? [...DEFAULT_CAPABILITIES], + }; + } + + 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: sessionKey, + name: slugifyAgentName(clientInfo?.name ?? "agent"), + label: anonymousAgentLabel(sessionKey), + owner: null, + caps: [...DEFAULT_CAPABILITIES], + }; + } + + async init() { + 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, + { description: tool.description, inputSchema: tool.schema }, + async (args: Record) => { + const identity = await this.identity(); + const result = await tool.run({ getStub, identity }, args); + return jsonContent(result); + }, + ); + } + + // 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 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."), + }, + }, + async ({ markdown }: { markdown?: string }) => { + // Same guards POST /new applies to uploaded content — this tool + // reaches the same document store, unauthenticated. + const invalid = validateNewDocumentMarkdown(markdown); + if (invalid) return jsonContent(invalid); + + 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" }, + }); + } + + // 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}` }); + }, + ); + } + + /** + * 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/agents/registry.ts b/agents/registry.ts new file mode 100644 index 00000000..c3f6223e --- /dev/null +++ b/agents/registry.ts @@ -0,0 +1,230 @@ +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; + email: string; + caps: AgentCapability[]; + codeChallenge: string; + redirectUri: string; + exp: number; +} + +export interface RefreshGrant { + clientId: string; + principal: string; + email: string; + caps: AgentCapability[]; + exp: number; +} + +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); + 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 }; + } + + /** 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:${await sha256Hex(token)}`, { + ...data, + exp: Date.now() + REFRESH_TTL_MS, + } satisfies RefreshGrant); + return { token }; + } + + /** 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 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:${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:${await sha256Hex(token)}`); + return { ok: true }; + } +} + +export default Registry; diff --git a/app/app.css b/app/app.css index 1f5921cd..c11b21c4 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 { @@ -43,124 +62,263 @@ body { .tiptap p { max-width: 65ch; - margin: 0; + margin: 0 0 0.5rem; } -.tiptap .collaboration-cursor__caret { - position: relative; - margin-left: -1px; - margin-right: -1px; - border-left: 2px solid; - word-break: normal; - pointer-events: none; +/* Rich-node typographic defaults (imported from the notes app) */ +.tiptap h1 { + font-size: 1.875rem; + font-weight: bold; + line-height: 1.3; + margin: 1.5rem 0 1rem; + max-width: 65ch; } -.tiptap .collaboration-cursor__label { - position: absolute; - top: -1.4em; - left: -1px; - font-size: 0.75rem; - font-family: var(--font-sans); - white-space: nowrap; - padding: 0.05rem 0.35rem; - border-radius: 2px 2px 2px 0; - color: white; - user-select: none; - pointer-events: none; +.tiptap h2 { + font-size: 1.5rem; + font-weight: bold; + line-height: 1.3; + margin: 1.25rem 0 0.75rem; + max-width: 65ch; } -/* Markdown decoration classes */ -.md-bold { - font-weight: 700; +.tiptap h3 { + font-size: 1.25rem; + font-weight: bold; + line-height: 1.3; + margin: 1rem 0 0.5rem; + max-width: 65ch; +} + +.tiptap h1:first-child, +.tiptap h2:first-child, +.tiptap h3:first-child { + margin-top: 0; +} + +.tiptap ul, +.tiptap ol { + margin: 0 0 0.5rem 1.5rem; + max-width: 65ch; +} + +.tiptap ul { + list-style-type: disc; +} + +.tiptap ol { + list-style-type: decimal; +} + +.tiptap li { + margin-bottom: 0.25rem; } -.md-italic { +.tiptap li > p { + margin-bottom: 0; +} + +.tiptap blockquote { + border-left: 4px solid var(--color-border); + padding-left: 1rem; font-style: italic; + color: var(--color-muted); + margin: 0 0 1rem; + max-width: 65ch; } -.md-code { - font-family: var(--font-mono); - font-size: 0.9em; +.tiptap code { background-color: var(--color-border); - padding: 0.1em 0.25em; - border-radius: 2px; + padding: 0.125rem 0.4rem; + border-radius: 0.25rem; + font-family: var(--font-mono); + font-size: 0.875em; } -.tiptap p.md-code-block { - font-family: var(--font-mono); - font-size: 0.9em; +.tiptap pre { background-color: var(--color-border); - max-width: none; - padding: 0 1em; + padding: 1rem; + border-radius: 0.25rem; + font-family: var(--font-mono); + font-size: 0.875rem; + margin: 0 0 1rem; + overflow-x: auto; } -.tiptap p.md-code-block-open { - padding-top: 0.5em; +.tiptap pre code { + background: none; + padding: 0; + display: block; + color: inherit; } -.tiptap p.md-code-block-close { - padding-bottom: 0.5em; +.tiptap hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 1.5rem 0; + max-width: 65ch; } -/* Syntax highlighting tokens (sugar-high) */ -.sh-keyword { color: #8b5cf6; } -.sh-string { color: #16a34a; } -.sh-comment { color: var(--color-muted); font-style: italic; } -.sh-class { color: #0891b2; } -.sh-property { color: #2563eb; } -.sh-entity { color: #c026d3; } -.sh-jsxliterals { color: #0891b2; } -.sh-sign { color: var(--color-muted); } +.tiptap a { + color: #2563eb; + cursor: pointer; +} -.md-strikethrough { - text-decoration: line-through; +.tiptap a:hover { + text-decoration: underline; } -.md-delimiter { - opacity: 0.35; +/* Task lists (schema lands with the WYSIWYG change; styles are ready) */ +.tiptap ul[data-type="taskList"] { + list-style-type: none; + margin-left: 0; + padding-left: 0; } -.md-heading-delimiter { - opacity: 0.35; +.tiptap ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5rem; } -.md-heading { - font-weight: 600; +.tiptap ul[data-type="taskList"] li > label { + flex: 0 0 auto; + margin-top: 0.125rem; } -.md-heading-1 { - font-size: 1.75em; - line-height: 1.3; +.tiptap ul[data-type="taskList"] li > div { + flex: 1; } -.md-heading-2 { - font-size: 1.4em; - line-height: 1.3; +.tiptap ul[data-type="taskList"] input[type="checkbox"] { + width: 1rem; + height: 1rem; + cursor: pointer; + accent-color: currentColor; } -.md-heading-3 { - font-size: 1.15em; - line-height: 1.3; +/* Tables (same status as task lists) */ +.tiptap .tableWrapper { + overflow-x: auto; + margin: 1rem 0; } -.md-link-text { - color: #2563eb; +.tiptap table { + border: 1px solid var(--color-border); + border-collapse: collapse; + table-layout: fixed; + width: 100%; + margin: 0; + overflow: hidden; +} + +.tiptap th, +.tiptap td { + border: 1px solid var(--color-border); + min-width: 1em; + padding: 0.5rem 0.75rem; + vertical-align: top; + box-sizing: border-box; + position: relative; +} + +.tiptap th { + font-weight: 600; + text-align: left; + background-color: color-mix(in srgb, var(--color-border) 40%, transparent); +} + +.tiptap .selectedCell { + background-color: color-mix(in srgb, var(--color-canary) 18%, transparent); } -.md-link-url { +/* Empty-document placeholder */ +.tiptap p.is-editor-empty:first-child::before { color: var(--color-muted); - text-decoration: underline; - cursor: pointer; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.tiptap .collaboration-cursor__caret { + position: relative; + margin-left: -1px; + margin-right: -1px; + border-left: 2px solid; + word-break: normal; + pointer-events: none; +} + +.tiptap .collaboration-cursor__label { + position: absolute; + top: -1.4em; + left: -1px; + font-size: 0.75rem; + font-family: var(--font-sans); + white-space: nowrap; + padding: 0.05rem 0.35rem; + border-radius: 2px 2px 2px 0; + color: white; + user-select: none; + 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, "wght" 300; +} + +/* 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__avatar { + width: 1em; + height: 1em; + border-radius: 50%; + margin-right: 0.3em; + vertical-align: -0.15em; + object-fit: cover; } -.md-hr { - opacity: 0.35; +/* 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; } -/* CriticMarkup delimiter tokens (widget decorations around mark ranges) */ -.cm-delimiter { - opacity: 0.35; - font-size: 0.85em; +.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%); } /* CriticMarkup mark styles (rendered as elements by TipTap marks) */ @@ -173,13 +331,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 */ @@ -192,7 +349,7 @@ body { display: inline-block; position: relative; width: 8px; - height: 4.5px; + height: 3px; background: var(--color-canary); vertical-align: -2px; margin: 0 1px; @@ -219,7 +376,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, @@ -227,18 +384,14 @@ body { display: none; } -/* Clean view: hide delimiters and comment text only */ -.clean-view .cm-delimiter { - display: none; -} - -.clean-view .cm-comment { +/* Comment text is data for the thread rail, not document prose — always + hidden inline; its point marker stays as the click target. */ +.tiptap .cm-comment { font-size: 0; - text-decoration: none; + border-bottom: none; } -/* Keep point markers visible inside hidden comment spans */ -.clean-view .cm-comment .cm-point-marker { +.tiptap .cm-comment .cm-point-marker { font-size: 1rem; } @@ -253,118 +406,19 @@ body { background-color: color-mix(in srgb, #3b82f6 20%, transparent); } -/* Preview styles */ -.preview { - max-width: 65ch; - padding: 1.5rem; - font-family: var(--font-serif); - font-size: 1.15rem; - line-height: 1.7; - color: var(--color-ink); -} - -.preview h1, -.preview h2, -.preview h3, -.preview h4, -.preview h5, -.preview h6 { - margin-top: 1.5em; - margin-bottom: 0.5em; - font-weight: 600; - line-height: 1.3; -} - -.preview h1 { font-size: 2em; } -.preview h2 { font-size: 1.5em; } -.preview h3 { font-size: 1.25em; } - -.preview p { - margin: 0.75em 0; -} - -.preview code { - font-family: var(--font-mono); - font-size: 0.9em; - background-color: var(--color-border); - padding: 0.1em 0.3em; - border-radius: 2px; -} - -.preview pre { - background-color: var(--color-border); - padding: 1em; - overflow-x: auto; - margin: 1em 0; -} - -.preview pre code { - background: none; - padding: 0; -} - -.preview blockquote { - border-left: 3px solid var(--color-border); - padding-left: 1em; - color: var(--color-muted); - margin: 1em 0; -} - -.preview ul { - list-style-type: disc; - padding-left: 1.5em; - margin: 0.75em 0; -} - -.preview ol { - list-style-type: decimal; - padding-left: 1.5em; - margin: 0.75em 0; -} - -.preview a { - color: #2563eb; - text-decoration: underline; -} - -/* CriticMarkup in rendered preview (same colors as editor marks) */ -.preview .cm-addition { - color: #22c55e; -} - -.preview .cm-deletion { - color: #ef4444; - text-decoration: line-through; -} - -.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; -} - /* Dark theme — explicit dark mode */ [data-theme="dark"] { --color-ink: #e5e5e5; --color-paper: #111111; --color-muted: #777; --color-border: #2a2a2a; + --color-accent: #262626; + --color-destructive: #f87171; color-scheme: dark; } -[data-theme="dark"] .md-link-text { color: #60a5fa; } [data-theme="dark"] .cm-addition { color: #4ade80; } [data-theme="dark"] .cm-deletion { color: #f87171; } -[data-theme="dark"] .preview a { color: #60a5fa; } -[data-theme="dark"] .sh-keyword { color: #a78bfa; } -[data-theme="dark"] .sh-string { color: #4ade80; } -[data-theme="dark"] .sh-class { color: #22d3ee; } -[data-theme="dark"] .sh-property { color: #60a5fa; } -[data-theme="dark"] .sh-entity { color: #e879f9; } -[data-theme="dark"] .sh-jsxliterals { color: #22d3ee; } /* Dark theme — auto mode follows system preference */ @media (prefers-color-scheme: dark) { @@ -373,19 +427,13 @@ body { --color-paper: #111111; --color-muted: #777; --color-border: #2a2a2a; + --color-accent: #262626; + --color-destructive: #f87171; color-scheme: dark; } - [data-theme="auto"] .md-link-text { color: #60a5fa; } [data-theme="auto"] .cm-addition { color: #4ade80; } [data-theme="auto"] .cm-deletion { color: #f87171; } - [data-theme="auto"] .preview a { color: #60a5fa; } - [data-theme="auto"] .sh-keyword { color: #a78bfa; } - [data-theme="auto"] .sh-string { color: #4ade80; } - [data-theme="auto"] .sh-class { color: #22d3ee; } - [data-theme="auto"] .sh-property { color: #60a5fa; } - [data-theme="auto"] .sh-entity { color: #e879f9; } - [data-theme="auto"] .sh-jsxliterals { color: #22d3ee; } } /* Onboarding marquee button */ diff --git a/app/components/AgentsPanel.tsx b/app/components/AgentsPanel.tsx new file mode 100644 index 00000000..88affefb --- /dev/null +++ b/app/components/AgentsPanel.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useId, 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({ open, onClose }: { open: boolean; onClose: () => void }) { + const params = useParams(); + const docId = params.id ?? ""; + const [roster, setRoster] = useState([]); + const titleId = useId(); + 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]); + + useEffect(() => { + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + + 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) onClose(); + } + + 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.label ?? entry.name} + {entry.label && ( + @{entry.name} + )} + + {entry.capabilities.map((c) => ( + + {c} + + ))} + + {entry.owner && ( + {entry.owner} + )} + {relativeTime(entry.lastSeenAt)} + + +
  • + ))} +
+ )} +
+
+
+
+ )} + + ); +} 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/CleanViewToggle.tsx b/app/components/CleanViewToggle.tsx deleted file mode 100644 index 02b39a8a..00000000 --- a/app/components/CleanViewToggle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useDocument } from "~/lib/DocumentContext"; - -export default function CleanViewToggle() { - const { cleanView, toggleCleanView } = useDocument(); - - return ( - - ); -} diff --git a/app/components/ConnectionStatus.tsx b/app/components/ConnectionStatus.tsx index 6c27b791..025c7857 100644 --- a/app/components/ConnectionStatus.tsx +++ b/app/components/ConnectionStatus.tsx @@ -1,50 +1,76 @@ import { useEffect, useState } from "react"; import { useDocument } from "~/lib/DocumentContext"; -const readyStates = { - [WebSocket.CONNECTING]: { - text: "Connecting", - dotClass: "bg-yellow-500", - }, - [WebSocket.OPEN]: { - text: "Connected", - dotClass: "bg-green-500", - }, - [WebSocket.CLOSING]: { - text: "Closing", - dotClass: "bg-orange-500", - }, - [WebSocket.CLOSED]: { - text: "Offline", - dotClass: "bg-red-500", - }, +type Status = "connected" | "connecting" | "reconnecting" | "sleeping" | "offline"; + +const DISPLAY: Record = { + connected: { text: "Connected", dotClass: "bg-green-500" }, + connecting: { text: "Connecting", dotClass: "bg-yellow-500", pulse: true }, + reconnecting: { text: "Reconnecting", dotClass: "bg-yellow-500", pulse: true }, + sleeping: { text: "Sleeping", dotClass: "bg-muted" }, + offline: { text: "Offline", dotClass: "bg-red-500" }, }; export default function ConnectionStatus() { const { yjs } = useDocument(); - const socket = yjs.socket; + const { socket, asleep } = yjs; const [readyState, setReadyState] = useState( - socket?.readyState === 1 ? 1 : 0, + socket?.readyState === WebSocket.OPEN ? WebSocket.OPEN : WebSocket.CONNECTING, + ); + const [online, setOnline] = useState( + typeof navigator === "undefined" ? true : navigator.onLine, ); - const display = readyStates[readyState as keyof typeof readyStates]; + const [everConnected, setEverConnected] = useState(false); useEffect(() => { if (!socket) return; - const onStateChange = () => setReadyState(socket.readyState); + const onStateChange = () => { + setReadyState(socket.readyState); + if (socket.readyState === WebSocket.OPEN) setEverConnected(true); + }; + onStateChange(); socket.addEventListener("open", onStateChange); socket.addEventListener("close", onStateChange); + socket.addEventListener("error", onStateChange); return () => { socket.removeEventListener("open", onStateChange); socket.removeEventListener("close", onStateChange); + socket.removeEventListener("error", onStateChange); }; }, [socket]); + useEffect(() => { + const onOnline = () => setOnline(true); + const onOffline = () => setOnline(false); + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, []); + + let status: Status; + if (asleep) { + status = "sleeping"; + } else if (!online) { + status = "offline"; + } else if (readyState === WebSocket.OPEN) { + status = "connected"; + } else { + // CONNECTING, CLOSING, or CLOSED while awake and online: the + // PartySocket retries with backoff, so all three read as (re)connecting. + status = everConnected ? "reconnecting" : "connecting"; + } + + const display = DISPLAY[status]; + return ( - + {display.text} diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 25c44e30..ec80f8e5 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -3,24 +3,16 @@ import { useEditor, EditorContent } from "@tiptap/react"; import { Extension, getMarkRange, type Editor as TiptapEditor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import Document from "@tiptap/extension-document"; -import Paragraph from "@tiptap/extension-paragraph"; -import Text from "@tiptap/extension-text"; +import StarterKit from "@tiptap/starter-kit"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCaret from "@tiptap/extension-collaboration-caret"; -import { CriticAddition, CriticDeletion, CriticComment, CriticHighlight, CriticDelimiters } from "~/lib/critic-marks"; -import { markdownDecorations, cleanViewKey } from "~/lib/markdown-decorations"; +import { CriticAddition, CriticDeletion, CriticComment, CriticHighlight, CriticPointMarkers } from "~/lib/critic-marks"; +import { BlockId } from "~/lib/block-id"; +import { parseMarkdown } from "~/shared/rich-markdown"; import { suggestModePlugin } from "~/lib/suggest-mode"; import BubbleToolbar from "~/components/BubbleToolbar"; import type { useYjsEditor } from "~/lib/useYjsEditor"; -const MarkdownDecorations = Extension.create({ - name: "markdownDecorations", - addProseMirrorPlugins() { - return markdownDecorations(); - }, -}); - const SuggestMode = Extension.create<{ docState: ReturnType["docState"] | null }>({ name: "suggestMode", addOptions() { @@ -180,8 +172,27 @@ function renderCaret(user: Record) { const label = document.createElement("div"); label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); + 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); + label.insertBefore(animal, null); + } 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; } @@ -193,7 +204,6 @@ export default function Editor({ onCommentClick, commentHighlight, activeCommentRange, - cleanView, onNewComment, onResolveAtCursor, onDeleteAtCursor, @@ -204,7 +214,6 @@ export default function Editor({ onCommentClick?: (commentText: string) => void; commentHighlight?: { from: number; to: number } | null; activeCommentRange?: { from: number; to: number } | null; - cleanView?: boolean; onNewComment?: () => void; onResolveAtCursor?: () => void; onDeleteAtCursor?: () => void; @@ -212,27 +221,35 @@ export default function Editor({ const { doc, awareness, user, docState } = yjs; const prevHighlightRef = useRef<{ from: number; to: number } | null>(null); const prevActiveRangeRef = useRef<{ from: number; to: number } | null>(null); - const prevCleanViewRef = useRef(false); const editor = useEditor( { immediatelyRender: false, extensions: [ - Document, - Paragraph, - Text, + StarterKit.configure({ + // Collaboration owns history; underline has no markdown form + // (see the markdown-completeness rule in the WYSIWYG plan). + undoRedo: false, + underline: false, + heading: { levels: [1, 2, 3] }, + link: { + openOnClick: false, + autolink: true, + linkOnPaste: true, + }, + }), + BlockId, CriticAddition, CriticDeletion, CriticComment, CriticHighlight, - CriticDelimiters, + CriticPointMarkers, Collaboration.configure({ document: doc }), CollaborationCaret.configure({ provider: { awareness }, user, render: renderCaret, }), - MarkdownDecorations, SuggestMode.configure({ docState }), CommentClickHandler.configure({ onCommentClick }), CommentHighlight, @@ -242,6 +259,20 @@ export default function Editor({ attributes: { class: "tiptap", }, + // Pasted plain text that looks like markdown parses to rich nodes — + // matching the old model where all text was markdown source. + handlePaste(view, event) { + const html = event.clipboardData?.getData("text/html"); + if (html) return false; + const text = event.clipboardData?.getData("text/plain"); + if (!text || !/[*_#>`~[\]]|\n|^-|\{[+\-=>]/m.test(text)) return false; + const parsed = parseMarkdown(text); + if (!parsed.ok) return false; + const { state, dispatch } = view; + const slice = parsed.doc.slice(0, parsed.doc.content.size); + dispatch(state.tr.replaceSelection(slice).scrollIntoView()); + return true; + }, }, }, [doc, awareness], @@ -267,17 +298,19 @@ export default function Editor({ prevActiveRangeRef.current = range; const tr = editor.state.tr.setMeta(activeCommentHighlightKey, range); editor.view.dispatch(tr); - }, [editor, activeCommentRange]); - // Update clean view state when prop changes - useEffect(() => { - if (!editor) return; - const isClean = cleanView ?? false; - if (isClean === prevCleanViewRef.current) return; - prevCleanViewRef.current = isClean; - const tr = editor.state.tr.setMeta(cleanViewKey, isClean); - editor.view.dispatch(tr); - }, [editor, cleanView]); + // Bring the highlighted phrase into view when a thread is selected. + // The dispatch above renders the active decoration synchronously. + if (range) { + let el: Element | null = editor.view.dom.querySelector(".cm-comment-active"); + if (!el) { + const pos = Math.min(range.from, editor.state.doc.content.size); + const dom = editor.view.domAtPos(pos).node; + el = dom instanceof HTMLElement ? dom : dom.parentElement; + } + el?.scrollIntoView({ block: "center" }); + } + }, [editor, activeCommentRange]); useEffect(() => { if (editor && onEditorReady) { @@ -298,7 +331,7 @@ export default function Editor({ return ( <>
diff --git a/app/components/FormatToolbar.tsx b/app/components/FormatToolbar.tsx new file mode 100644 index 00000000..da3ba03c --- /dev/null +++ b/app/components/FormatToolbar.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from "react"; +import { useDocument } from "~/lib/DocumentContext"; +import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from "~/components/ui/menu"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import Icon from "~/components/Icon"; +import { cn } from "~/lib/cn"; + +function useEditorTick() { + const { editorInstance: editor } = useDocument(); + const [, setTick] = useState(0); + useEffect(() => { + if (!editor) return; + const bump = () => setTick((t) => t + 1); + editor.on("transaction", bump); + editor.on("focus", bump); + editor.on("blur", bump); + return () => { + editor.off("transaction", bump); + editor.off("focus", bump); + editor.off("blur", bump); + }; + }, [editor]); + return editor; +} + +const triggerClass = + "flex h-full cursor-pointer items-center px-2.5 transition-colors hover:bg-border"; + +/** + * The formatting toolbar, imported from the notes app: Format, Lists, and + * Insert menus in the document header. The group dims while the editor is + * unfocused and restores on hover, focus, or an open menu. + */ +export default function FormatToolbar() { + const editor = useEditorTick(); + const [openMenus, setOpenMenus] = useState(0); + const [hovered, setHovered] = useState(false); + const [showLinkDialog, setShowLinkDialog] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [linkTitle, setLinkTitle] = useState(""); + + if (!editor) return null; + + const dimmed = !editor.isFocused && !hovered && openMenus === 0; + const onOpenChange = (open: boolean) => setOpenMenus((n) => (open ? n + 1 : Math.max(0, n - 1))); + + const markButton = (mark: string, icon: string, label: string, toggle: () => void) => ( + + ); + + 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/components/HeaderMenu.tsx b/app/components/HeaderMenu.tsx new file mode 100644 index 00000000..df2c9e00 --- /dev/null +++ b/app/components/HeaderMenu.tsx @@ -0,0 +1,163 @@ +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 { + google?: { + accounts: { + id: { + initialize: (opts: { client_id: string; callback: (r: { credential: string }) => void }) => void; + renderButton: (el: HTMLElement, opts: Record) => void; + }; + }; + }; + } +} + +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: "computer", label: "Auto" }, +]; + +/** + * The top-right header menu: connection status, the Agents panel, the + * account row (Google sign-in or name + sign-out), and the theme switcher. + */ +export default function HeaderMenu({ onOpenAgents }: { onOpenAgents: () => void }) { + const session = useSession(); + const { theme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + const buttonHost = useRef(null); + + // Load Google Identity Services and render its button only while the menu + // is open with no active session. + useEffect(() => { + if (!open || session?.signedIn || !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) 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, session?.signedIn]); + + async function signOut() { + await fetch("/auth/logout", { method: "POST" }); + notifyAuthChanged(); + } + + return ( + <> + + {open && ( + <> +
setOpen(false)} /> +
+ + {session?.signedIn ? ( +
+ + {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/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/components/MobilePanel.tsx b/app/components/MobilePanel.tsx index 9c562f94..ceb08b6d 100644 --- a/app/components/MobilePanel.tsx +++ b/app/components/MobilePanel.tsx @@ -1,10 +1,7 @@ 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"; type Tab = "editing" | "comments" | "preview"; @@ -23,7 +20,7 @@ export default function MobilePanel({ className }: { className?: string }) { // Switch to comments tab when a thread is activated (e.g. clicking in editor) useEffect(() => { if (activeThreadId && activeThreadId !== prevThreadIdRef.current) { - setActiveTab("comments"); // eslint-disable-line react-hooks/set-state-in-effect + setActiveTab("comments"); } prevThreadIdRef.current = activeThreadId; }, [activeThreadId]); @@ -57,13 +54,6 @@ export default function MobilePanel({ className }: { className?: string }) { className="overflow-y-auto" style={{ height: "calc(33vh - 48px)" }} > - {activeTab === "editing" && ( - <> - - - - - )} {activeTab === "comments" && ( <> diff --git a/app/components/ModeMenu.tsx b/app/components/ModeMenu.tsx new file mode 100644 index 00000000..615f7fca --- /dev/null +++ b/app/components/ModeMenu.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +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"; + +/** + * 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(); + 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 = "gap-2"; + + return ( + + + + + + { + setMode("edit"); + if (showPreview) togglePreview(); + }} + > + + 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/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/OnboardingBanner.tsx b/app/components/OnboardingBanner.tsx index 900462f4..cb5d2b9c 100644 --- a/app/components/OnboardingBanner.tsx +++ b/app/components/OnboardingBanner.tsx @@ -24,23 +24,21 @@ export default function OnboardingBanner() { if (!isOnboarding) return null; return ( -
- -
+ start editing + start editing + + ); } diff --git a/app/components/Preview.tsx b/app/components/Preview.tsx index 198d45ad..e85ec686 100644 --- a/app/components/Preview.tsx +++ b/app/components/Preview.tsx @@ -1,30 +1,16 @@ -import { useMemo } from "react"; -import { marked } from "marked"; -import DOMPurify from "dompurify"; import { useDocument } from "~/lib/DocumentContext"; -/** Replace CriticMarkup delimiters with styled HTML spans before markdown rendering */ -function renderCriticMarkup(text: string): string { - return text - .replace(/\{--(.+?)--\}/g, '$1') - .replace(/\{\+\+(.+?)\+\+\}/g, '$1') - .replace(/\{>>(.+?)<<\}/g, '') - .replace(/\{==(.+?)==\}/g, '$1'); -} - +/** + * The Markdown source view — the inverse of the old rendered preview: the + * editor is now the rendered view, so this shows the document's canonical + * markdown serialization, read-only. + */ export default function Preview() { const { markdown } = useDocument(); - const html = useMemo(() => { - const withCritic = renderCriticMarkup(markdown); - const raw = marked.parse(withCritic, { async: false }) as string; - return DOMPurify.sanitize(raw); - }, [markdown]); - return ( -
+
+      {markdown}
+    
); } diff --git a/app/components/ShareButton.tsx b/app/components/ShareButton.tsx index 86c25247..43a8a5c3 100644 --- a/app/components/ShareButton.tsx +++ b/app/components/ShareButton.tsx @@ -1,7 +1,8 @@ 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"; +import Icon from "~/components/Icon"; export default function ShareButton() { const { docId, markdown, threads } = useDocument(); @@ -25,38 +26,25 @@ export default function ShareButton() { }, [docId, markdown, threads]); return ( - - + + - - - - - {copied ? "\u2713 Copied" : "Copy link"} - - - Download - - - - + + + + + {copied ? "Copied" : "Copy link"} + + + + Download + + + ); } diff --git a/app/components/SuggestionActions.tsx b/app/components/SuggestionActions.tsx deleted file mode 100644 index 7950ea74..00000000 --- a/app/components/SuggestionActions.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useCallback, useState, useEffect } from "react"; -import { useDocument } from "~/lib/DocumentContext"; -import { - hasSuggestionMarkup, - isCursorInSuggestion, - processAllRanges, - processRangeAtCursor, -} from "~/lib/suggestion-actions"; - -export default function SuggestionActions() { - const { editorInstance: editor, mode } = useDocument(); - const [hasSuggestions, setHasSuggestions] = useState(false); - const [cursorInRange, setCursorInRange] = useState(false); - - useEffect(() => { - 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 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); - }, [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/ThemeSelector.tsx b/app/components/ThemeSelector.tsx index c5c0cb87..95934141 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: "computer", 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/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) => (
max ? text.slice(0, max) + "\u2026" : text; +function AuthorHeader({ + author, + timestamp, + children, +}: { + author: ThreadData["author"]; + timestamp: number; + children?: React.ReactNode; +}) { + return ( +
+ +
+ {author.name} + {timeAgo(timestamp)} +
+ {children} +
+ ); } interface ThreadPanelProps { @@ -34,20 +58,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 +83,6 @@ export default function ThreadPanel({ handleReplySubmit(); } else if (e.key === "Escape") { setReplyText(""); - setShowReplyInput(false); } }, [handleReplySubmit], @@ -65,77 +90,77 @@ export default function ThreadPanel({ return (
onSelect(active ? null : thread.id)} > - {/* Author + timestamp */} -
- {thread.author.name} - {timeAgo(thread.createdAt)} -
- - {/* Highlight context */} - {thread.highlightText && ( -
- {truncate(thread.highlightText, 80)} + {/* Author + timestamp + actions */} + +
e.stopPropagation()} + > + +
+ + {menuOpen && ( +
+ +
+ )} +
- )} +
{/* Comment text */} -

{thread.commentText}

+

{thread.commentText}

{/* Replies */} {thread.replies.length > 0 && ( -
+
{thread.replies.map((reply) => (
-
- {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/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 ( + +
+ ); +} + +export default function Home({ loaderData }: Route.ComponentProps) { + const { origin } = loaderData; + const navigate = useNavigate(); + const fileInputRef = useRef(null); + async function handleNewDocument() { const { body, threads, onboarding } = deserializeThreads(demoDocument); const id = generateDocumentId(); @@ -40,7 +84,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 +100,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { body: JSON.stringify({ content: body, threads }), }); - navigate(`/docs/${id}`); + navigate(`/${id}`); }, [navigate], ); @@ -94,7 +138,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {

{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. +

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/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 new file mode 100644 index 00000000..3857c55a --- /dev/null +++ b/app/shared/agent-protocol.ts @@ -0,0 +1,156 @@ +export type AgentCapability = "comment" | "suggest" | "write"; +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[]; + createdAt: number; + lastSeenAt: number | null; +} + +export interface BlockAnchor { + index: number; + hash: string; // 8 hex chars +} + +export interface DocBlock extends BlockAnchor { + /** Persistent block id (null only on pre-block-id documents). */ + id: string | null; + 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 slug (agentSlug or slugified clientInfo) — used for @mentions + /** 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[]; +} + +export interface AgentError { + code: AgentErrorCode; + message: string; + snippet?: string; +} + +export type AgentErrorCode = + | "stale_anchor" + /** Anchor's block id exists but its content hash is out of date. */ + | "stale_block" + | "capability_denied" + | "invalid_token" + | "doc_not_found" + | "doc_expired" + | "find_not_matched" + | "rate_limited" + | "invalid_name" + | "thread_not_found" + /** Markdown the editor's mark model can't represent (CriticMarkup substitution). */ + | "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]$/; + +/** + * Root slugs that can never be a document id: documents live at `/:id`, so + * the root path is one shared namespace with these routes and well-known + * files. Enforced in two places — generateDocumentId never mints one + * (app/shared/constants.ts) and the `/:id` loader 404s them outright + * (app/routes/doc.$id.tsx). + */ +export const RESERVED_SLUGS = [ + "new", + "mcp", + "agents", + "api", + "assets", + "demo", + "favicon.ico", + "robots.txt", + ".well-known", + "auth", + "oauth", + "settings", + "privacy", + "terms", +]; + +/** Whether a root slug is reserved (case-insensitive — URLs aren't). */ +export function isReservedSlug(slug: string): boolean { + return RESERVED_SLUGS.includes(slug.toLowerCase()); +} + +/** + * The most agents one document's roster can hold. A document is a shared, + * unauthenticated URL, so the roster needs a ceiling: without one, anyone who + * can reach the invite endpoint can grow it without bound. + */ +export const MAX_AGENTS_PER_DOC = 16; +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; +} + +/** + * Turns an arbitrary string (an MCP client's `clientInfo.name`, typically) + * into a valid agent name: lowercased, non-slug characters collapsed to a + * single hyphen, leading/trailing hyphens trimmed, clamped to the 32-char + * limit `AGENT_NAME_RE` allows. Falls back to `"agent"` when nothing usable + * survives (empty input, symbols only, a single character). + */ +export function slugifyAgentName(raw: string): string { + const slug = raw + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) + .replace(/-+$/g, ""); + return AGENT_NAME_RE.test(slug) ? slug : "agent"; +} + +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/app/shared/anon-animals.ts b/app/shared/anon-animals.ts new file mode 100644 index 00000000..cbf25880 --- /dev/null +++ b/app/shared/anon-animals.ts @@ -0,0 +1,79 @@ +/** + * 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; +} + +/** + * 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" }, + { 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/constants.ts b/app/shared/constants.ts index df54b960..5878be53 100644 --- a/app/shared/constants.ts +++ b/app/shared/constants.ts @@ -1,4 +1,6 @@ -export const APP_NAME = "mist"; +import { isReservedSlug } from "./agent-protocol"; + +export const APP_NAME = "vapor"; export function isValidDocumentId(id: string): boolean { if (id.length !== 8) return false; @@ -8,12 +10,20 @@ export function isValidDocumentId(id: string): boolean { const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; const ID_LENGTH = 8; +/** + * Mints a random document id. Documents live at `/:id`, so the id shares a + * namespace with the reserved root slugs — a collision is re-rolled rather + * than returned. Cheap and defensive: no current reserved slug has this + * shape, but the list is the kind of thing that grows. + */ export function generateDocumentId(): string { - let id = ""; - for (let i = 0; i < ID_LENGTH; i++) { - id += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)]; + for (;;) { + let id = ""; + for (let i = 0; i < ID_LENGTH; i++) { + id += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)]; + } + if (!isReservedSlug(id)) return id; } - return id; } export const USER_COLOURS = [ diff --git a/app/shared/rich-markdown.ts b/app/shared/rich-markdown.ts new file mode 100644 index 00000000..f794d294 --- /dev/null +++ b/app/shared/rich-markdown.ts @@ -0,0 +1,512 @@ +/** + * The document's serialization core: one ProseMirror schema shared by the + * client editor and the DocumentAgent, a deterministic markdown + * parser/serializer for it (GFM subset + CriticMarkup), and converters + * between markdown, ProseMirror nodes, and the Yjs XmlFragment encoding + * used by y-tiptap. + * + * Markdown is the interchange dialect everywhere (agents, exports, raw + * endpoints); the CRDT holds rich nodes. Every node and mark here has a + * canonical markdown form — anything without one doesn't belong in the + * schema (see docs/plans/2026-08-31-wysiwyg-editing-plan.md). + */ +import { Schema, type Node as PMNode } from "@tiptap/pm/model"; +import { MarkdownParser, MarkdownSerializer } from "prosemirror-markdown"; +import MarkdownIt from "markdown-it"; +import * as Y from "yjs"; +import { yXmlFragmentToProseMirrorRootNode } from "@tiptap/y-tiptap"; +import { blockHash, parseAnchor as parseLegacyAnchor, type DocBlock } from "./agent-protocol"; + +/* ---------- Schema (names must match the TipTap extensions) ---------- */ + +const blockIdAttr = { blockId: { default: null as string | null } }; + +export const richSchema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { content: "inline*", group: "block", attrs: blockIdAttr }, + heading: { + content: "inline*", + group: "block", + attrs: { ...blockIdAttr, level: { default: 1 } }, + defining: true, + }, + blockquote: { content: "block+", group: "block", attrs: blockIdAttr, defining: true }, + codeBlock: { + content: "text*", + group: "block", + attrs: { ...blockIdAttr, language: { default: null as string | null } }, + marks: "", + code: true, + defining: true, + }, + bulletList: { content: "listItem+", group: "block", attrs: blockIdAttr }, + orderedList: { + content: "listItem+", + group: "block", + attrs: { ...blockIdAttr, start: { default: 1 } }, + }, + listItem: { content: "paragraph block*", defining: true }, + horizontalRule: { group: "block", attrs: blockIdAttr }, + hardBreak: { inline: true, group: "inline", selectable: false }, + text: { inline: true, group: "inline" }, + }, + marks: { + link: { + attrs: { + href: { default: null as string | null }, + target: { default: null as string | null }, + rel: { default: null as string | null }, + class: { default: null as string | null }, + }, + inclusive: false, + }, + bold: {}, + italic: {}, + strike: {}, + code: {}, + criticAddition: { inclusive: false, excludes: "criticDeletion criticComment" }, + criticDeletion: { inclusive: false, excludes: "criticAddition criticComment" }, + criticComment: { inclusive: false, excludes: "criticAddition criticDeletion" }, + criticHighlight: { + inclusive: false, + attrs: { threadId: { default: null as string | null } }, + }, + }, +}); + +/* ---------- Block ids ---------- */ + +export const BLOCK_ID_RE = /^[a-z0-9]{8}$/; + +export function mintBlockId(): string { + return (Math.random().toString(36).slice(2) + "00000000").slice(0, 8); +} + +/** Node types that carry a blockId at the top level of the document. */ +export const BLOCK_ID_TYPES = [ + "paragraph", + "heading", + "blockquote", + "codeBlock", + "bulletList", + "orderedList", + "horizontalRule", +]; + +/* ---------- markdown-it with CriticMarkup inline syntax ---------- */ + +const CRITIC_KINDS: [open: string, close: string, name: string][] = [ + ["{++", "++}", "criticAddition"], + ["{--", "--}", "criticDeletion"], + ["{==", "==}", "criticHighlight"], + ["{>>", "<<}", "criticComment"], +]; + +type MdState = { + src: string; + pos: number; + posMax: number; + push: (type: string, tag: string, nesting: number) => unknown; + md: { inline: { tokenize: (state: MdState) => void } }; +}; + +function criticRule(state: MdState, silent: boolean): boolean { + const { src, pos } = state; + if (src.charCodeAt(pos) !== 0x7b /* { */) return false; + for (const [open, close, name] of CRITIC_KINDS) { + if (!src.startsWith(open, pos)) continue; + const end = src.indexOf(close, pos + open.length); + if (end < 0 || end > state.posMax) return false; + if (!silent) { + state.push(`${name}_open`, "span", 1); + const oldPos = state.pos; + const oldMax = state.posMax; + state.pos = pos + open.length; + state.posMax = end; + state.md.inline.tokenize(state); + state.pos = oldPos; + state.posMax = oldMax; + state.push(`${name}_close`, "span", -1); + } + state.pos = end + close.length; + return true; + } + return false; +} + +function makeMarkdownIt() { + const md = new MarkdownIt({ html: false, linkify: true }); + // Not representable in the schema — leave their syntax as literal text. + md.disable(["image", "table"]); + md.inline.ruler.before("emphasis", "critic", criticRule as never); + return md; +} + +/* ---------- Parser ---------- */ + +export const markdownParser = new MarkdownParser(richSchema, makeMarkdownIt() as never, { + blockquote: { block: "blockquote" }, + paragraph: { block: "paragraph" }, + list_item: { block: "listItem" }, + bullet_list: { block: "bulletList" }, + ordered_list: { + block: "orderedList", + getAttrs: (tok) => ({ start: Number(tok.attrGet("start")) || 1 }), + }, + heading: { + block: "heading", + getAttrs: (tok) => ({ level: Math.min(Number(tok.tag.slice(1)) || 1, 3) }), + }, + code_block: { block: "codeBlock", noCloseToken: true }, + fence: { + block: "codeBlock", + getAttrs: (tok) => ({ language: tok.info.trim() || null }), + noCloseToken: true, + }, + hr: { node: "horizontalRule" }, + hardbreak: { node: "hardBreak" }, + em: { mark: "italic" }, + strong: { mark: "bold" }, + s: { mark: "strike" }, + link: { + mark: "link", + getAttrs: (tok) => ({ href: tok.attrGet("href") }), + }, + code_inline: { mark: "code", noCloseToken: true }, + criticAddition: { mark: "criticAddition" }, + criticDeletion: { mark: "criticDeletion" }, + criticHighlight: { mark: "criticHighlight" }, + criticComment: { mark: "criticComment" }, +}); + +/* ---------- Serializer ---------- */ + +function backticksFor(node: PMNode, side: -1 | 1): string { + const ticks = /`+/g; + let len = 0; + if (node.isText) { + let m: RegExpExecArray | null; + while ((m = ticks.exec(node.text ?? ""))) len = Math.max(len, m[0].length); + } + let result = len > 0 && side > 0 ? " `" : "`"; + for (let i = 0; i < len; i++) result += "`"; + if (len > 0 && side < 0) result += " "; + return result; +} + +export const markdownSerializer = new MarkdownSerializer( + { + blockquote(state, node) { + state.wrapBlock("> ", null, node, () => state.renderContent(node)); + }, + codeBlock(state, node) { + state.write("```" + (node.attrs.language ?? "") + "\n"); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write("```"); + state.closeBlock(node); + }, + heading(state, node) { + state.write("#".repeat(node.attrs.level as number) + " "); + state.renderInline(node, false); + state.closeBlock(node); + }, + horizontalRule(state, node) { + state.write("---"); + state.closeBlock(node); + }, + bulletList(state, node) { + state.renderList(node, " ", () => "- "); + }, + orderedList(state, node) { + const start = (node.attrs.start as number) || 1; + const maxW = String(start + node.childCount - 1).length; + const space = " ".repeat(maxW + 2); + state.renderList(node, space, (i) => { + const nStr = String(start + i); + return " ".repeat(maxW - nStr.length) + nStr + ". "; + }); + }, + listItem(state, node) { + state.renderContent(node); + }, + paragraph(state, node) { + state.renderInline(node); + state.closeBlock(node); + }, + hardBreak(state, node, parent, index) { + for (let i = index + 1; i < parent.childCount; i++) { + if (parent.child(i).type !== node.type) { + state.write("\\\n"); + return; + } + } + }, + text(state, node) { + state.text(node.text ?? ""); + }, + }, + { + link: { + open: "[", + close(state, mark) { + return "](" + (mark.attrs.href as string ?? "") + ")"; + }, + mixable: false, + }, + bold: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true }, + italic: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true }, + strike: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true }, + code: { + open(_state, _mark, parent, index) { + return backticksFor(parent.child(index), -1); + }, + close(_state, _mark, parent, index) { + return backticksFor(parent.child(index - 1), 1); + }, + escape: false, + }, + criticAddition: { open: "{++", close: "++}", mixable: true }, + criticDeletion: { open: "{--", close: "--}", mixable: true }, + criticHighlight: { open: "{==", close: "==}", mixable: true }, + criticComment: { open: "{>>", close: "<<}", mixable: true }, + }, +); + +const SERIALIZE_OPTS = { tightLists: true }; + +export function serializePmDoc(doc: PMNode): string { + return markdownSerializer.serialize(doc, SERIALIZE_OPTS).replace(/\n+$/, ""); +} + +function serializeBlockNode(node: PMNode): string { + return markdownSerializer + .serialize(richSchema.node("doc", null, [node]), SERIALIZE_OPTS) + .replace(/\n+$/, ""); +} + +/** Parses markdown into a ProseMirror doc. Returns an error instead of throwing. */ +export function parseMarkdown( + markdown: string, +): { ok: true; doc: PMNode } | { ok: false; message: string } { + try { + const doc = markdownParser.parse(markdown); + if (!doc) return { ok: false, message: "Empty document" }; + return { ok: true, doc }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : "Unsupported markdown" }; + } +} + +/* ---------- Y.Doc conversions ---------- */ + +function pmRootFromY(doc: Y.Doc): PMNode | null { + const frag = doc.getXmlFragment("default"); + if (frag.length === 0) return null; + return yXmlFragmentToProseMirrorRootNode(frag, richSchema); +} + +export function yDocToMarkdown(doc: Y.Doc): string { + const root = pmRootFromY(doc); + return root ? serializePmDoc(root) : ""; +} + +export function getBlocks(doc: Y.Doc): DocBlock[] { + const root = pmRootFromY(doc); + if (!root) return []; + const blocks: DocBlock[] = []; + root.forEach((child, _offset, index) => { + const text = serializeBlockNode(child); + blocks.push({ + index, + id: (child.attrs.blockId as string | null) ?? null, + hash: blockHash(text), + text, + }); + }); + return blocks; +} + +export function formatAnchor(b: DocBlock): string { + return b.id ? `${b.id}-${b.hash}` : `b${b.index}-${b.hash}`; +} + +/** + * Resolves an anchor to a block index. Anchors are `{blockId}-{hash}`; + * the id is identity, the hash a staleness check — a found id with a + * mismatched hash is `stale_block` (the block changed since the caller + * read it) and the snippet carries the block's current state so the + * caller can retry without a full re-read. Legacy `b{index}-{hash}` + * anchors (pre-block-id documents) fall back to hash matching. + */ +export function resolveAnchor( + doc: Y.Doc, + anchor: string, +): { index: number } | { error: "stale_anchor" | "stale_block"; snippet: string } { + const blocks = getBlocks(doc); + const overview = () => + blocks + .slice(0, 6) + .map((b) => `[${formatAnchor(b)}] ${b.text.slice(0, 60)}`) + .join("\n"); + + const m = /^([a-z0-9]{8})-([0-9a-f]{8})$/.exec(anchor); + if (m) { + const byId = blocks.find((b) => b.id === m[1]); + if (byId) { + if (byId.hash !== m[2]) { + return { + error: "stale_block", + snippet: `[${formatAnchor(byId)}] ${byId.text.slice(0, 200)}`, + }; + } + return { index: byId.index }; + } + } + + const legacy = parseLegacyAnchor(anchor); + if (legacy) { + const matches = blocks.filter((b) => b.hash === legacy.hash); + if (matches.length > 0) { + const best = matches.reduce((a, b) => + Math.abs(a.index - legacy.index) <= Math.abs(b.index - legacy.index) ? a : b, + ); + return { index: best.index }; + } + } + + return { error: "stale_anchor", snippet: overview() }; +} + +/* ---------- PM → Y (mirrors y-tiptap's encoding) ---------- */ + +type TextRun = { text: string; attrs: Record | undefined }; + +function marksToYAttributes(node: PMNode): Record | undefined { + if (node.marks.length === 0) return undefined; + const attrs: Record = {}; + for (const mark of node.marks) attrs[mark.type.name] = mark.attrs; + return attrs; +} + +export function pmNodeToYElement(node: PMNode): Y.XmlElement { + const el = new Y.XmlElement(node.type.name); + for (const [key, val] of Object.entries(node.attrs)) { + if (val !== null) el.setAttribute(key, val as string); + } + + const children: (Y.XmlElement | Y.XmlText)[] = []; + let textGroup: TextRun[] = []; + const flushText = () => { + if (textGroup.length === 0) return; + const ytext = new Y.XmlText(); + ytext.applyDelta( + textGroup.map((run) => ({ insert: run.text, attributes: run.attrs })), + ); + children.push(ytext); + textGroup = []; + }; + + node.forEach((child) => { + if (child.isText) { + textGroup.push({ text: child.text ?? "", attrs: marksToYAttributes(child) }); + } else { + flushText(); + children.push(pmNodeToYElement(child)); + } + }); + flushText(); + + if (children.length > 0) el.insert(0, children); + return el; +} + +/** + * Parses markdown into detached rich block elements, minting block ids, + * without touching any document. Split from the insert so callers can + * validate first — Yjs has no transaction rollback (see the `replace` + * mutation). + */ +export function buildMarkdownBlocks( + markdown: string, +): { ok: true; nodes: Y.XmlElement[] } | { ok: false; message: string } { + const parsed = parseMarkdown(markdown); + if (!parsed.ok) return parsed; + const nodes: Y.XmlElement[] = []; + parsed.doc.forEach((child) => { + const withId = + child.attrs.blockId == null && "blockId" in child.attrs + ? child.type.create({ ...child.attrs, blockId: mintBlockId() }, child.content, child.marks) + : child; + nodes.push(pmNodeToYElement(withId)); + }); + return { ok: true, nodes }; +} + +/** Inserts prebuilt block elements (from buildMarkdownBlocks) at `index`. */ +export function insertBlockNodes(doc: Y.Doc, index: number, nodes: Y.XmlElement[]): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => { + frag.insert(index, nodes); + }); +} + +export function deleteBlocks(doc: Y.Doc, from: number, to: number): void { + const frag = doc.getXmlFragment("default"); + doc.transact(() => frag.delete(from, to - from + 1)); +} + +/* ---------- Typed-performance support ---------- */ + +export interface TypedBlockFill { + ytext: Y.XmlText; + runs: TextRun[]; +} + +/** + * Builds a block element whose text nodes start EMPTY, plus the ordered + * list of fills to type into them — the performance engine inserts the + * skeleton synchronously (claiming its slot), then types each run in + * chunks with its formatting attributes, so styled text styles while it + * is typed. + */ +export function buildTypedBlock(node: PMNode): { element: Y.XmlElement; fills: TypedBlockFill[] } { + const fills: TypedBlockFill[] = []; + + function build(n: PMNode): Y.XmlElement { + const el = new Y.XmlElement(n.type.name); + for (const [key, val] of Object.entries(n.attrs)) { + if (val !== null) el.setAttribute(key, val as string); + } + const children: (Y.XmlElement | Y.XmlText)[] = []; + let textGroup: TextRun[] = []; + const flushText = () => { + if (textGroup.length === 0) return; + const ytext = new Y.XmlText(); + fills.push({ ytext, runs: textGroup }); + children.push(ytext); + textGroup = []; + }; + n.forEach((child) => { + if (child.isText) { + // Explicit {} (not undefined): a Y.Text insert without attributes + // inherits the formatting at the insertion point, which would smear + // the previous run's marks over this one during typed fills. + textGroup.push({ text: child.text ?? "", attrs: marksToYAttributes(child) ?? {} }); + } else { + flushText(); + children.push(build(child)); + } + }); + flushText(); + if (children.length > 0) el.insert(0, children); + return el; + } + + const withId = + node.attrs.blockId == null && "blockId" in node.attrs + ? node.type.create({ ...node.attrs, blockId: mintBlockId() }, node.content, node.marks) + : node; + return { element: build(withId), fills }; +} diff --git a/app/shared/types.ts b/app/shared/types.ts index aa87fe20..80a7f767 100644 --- a/app/shared/types.ts +++ b/app/shared/types.ts @@ -2,6 +2,12 @@ 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; + /** Avatar image URL for a signed-in user (from Google), if any. */ + avatar?: string; } export type DocMode = "edit" | "suggest"; diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md index e6097d2b..44a2157a 100644 --- a/docs/markdown-and-criticmarkup.md +++ b/docs/markdown-and-criticmarkup.md @@ -75,13 +75,13 @@ Each suggestion (addition or deletion) can be accepted or rejected: ## Comments and threads -Comment threads are stored in **YAML frontmatter** under the `mist` key. The frontmatter is prepended on download and stripped on upload. +Comment threads are stored in **YAML frontmatter** under the `vapor` key. The frontmatter is prepended on download and stripped on upload. ### Format ```yaml --- -mist: +vapor: threads: - comment: "This needs a citation" highlight: "highlighted passage" 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..9d7b9919 --- /dev/null +++ b/docs/plans/2026-08-30-agent-collaborators-design.md @@ -0,0 +1,147 @@ +# 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. + +## 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. + +## Anonymous agents (added 2026-08-30, post-v1) + +The bearer token is optional. A tokenless MCP session gets an **anonymous agent identity**: + +- The name derives from the MCP client's `clientInfo.name` at initialize, slugified to `AGENT_NAME_RE` (fallback `agent`); a collision inside a doc appends `-2`, `-3`, …. +- On the first tool call that touches a document, `VaporMcp` auto-enrolls the agent: a server-generated token is minted with `DEFAULT_CAPABILITIES` (suggest + comment) and `owner: null`, held in the MCP session's state and never shown to anyone. All downstream RPCs, rate limits, roster UI, and revoke work unchanged — an anonymous agent is an ordinary roster entry. +- `write` still requires an explicitly minted token. The existing `MAX_AGENTS_PER_DOC` cap bounds roster flooding. +- Escalation argument: vapor is public by URL — any human with the link can already edit anonymously over the Yjs WebSocket, so a tokenless agent with suggest rights grants nothing new. + +Connecting becomes one line with no token: + +``` +claude mcp add --transport http vapor https://vapor.fyi/mcp +``` + +Explicit agent naming and cross-doc user tokens are the next phase (user-level credentials), not this one. + +## 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. 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. 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..9590c3fd --- /dev/null +++ b/docs/plans/2026-08-30-identity-design.md @@ -0,0 +1,109 @@ +# 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 + +Identity is the default; anonymity is the explicitly chosen door: + +- **`/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: 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 (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. +- **`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 + +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 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. + +## 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. +- 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` 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. 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..bd0172b0 --- /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 "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 + +**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. 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..b1646367 --- /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 / 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. +- **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, 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 ⌘⇧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`. + +## 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-mcp-events-polyfill-plan.md b/docs/plans/2026-08-31-mcp-events-polyfill-plan.md new file mode 100644 index 00000000..bde4045d --- /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; `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 + +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 & 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) + +- 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). 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..7501751f --- /dev/null +++ b/docs/plans/2026-08-31-wysiwyg-editing-plan.md @@ -0,0 +1,66 @@ +# 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. + +## 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 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). 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; 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 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.
+
+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).
+- **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
+
+Toolbar buttons (plan 4), tables/task-list UI, attachments, version history (roadmap).
diff --git a/package-lock.json b/package-lock.json
index 7b94d0d1..a6d0e830 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,32 +7,32 @@
 			"name": "mist",
 			"hasInstallScript": true,
 			"dependencies": {
-				"@radix-ui/react-dropdown-menu": "^2.1.16",
-				"@radix-ui/react-switch": "^1.2.6",
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/extension-bubble-menu": "^3.19.0",
-				"@tiptap/extension-collaboration": "^3.19.0",
-				"@tiptap/extension-collaboration-caret": "^3.19.0",
-				"@tiptap/extension-document": "^3.19.0",
-				"@tiptap/extension-paragraph": "^3.19.0",
-				"@tiptap/extension-text": "^3.19.0",
-				"@tiptap/pm": "^3.19.0",
-				"@tiptap/react": "^3.19.0",
+				"@base-ui/react": "^1.7.0",
+				"@modelcontextprotocol/sdk": "1.25.2",
+				"@tiptap/core": "3.30.5",
+				"@tiptap/extension-bubble-menu": "3.30.5",
+				"@tiptap/extension-collaboration": "3.30.5",
+				"@tiptap/extension-collaboration-caret": "3.30.5",
+				"@tiptap/pm": "3.30.5",
+				"@tiptap/react": "3.30.5",
+				"@tiptap/starter-kit": "3.30.5",
 				"@tiptap/y-tiptap": "^3.0.2",
 				"agents": "^0.3.6",
+				"clsx": "^2.1.1",
 				"critic-markup": "^2.0.0",
-				"dompurify": "^3.3.3",
 				"fathom-client": "^3.7.2",
 				"isbot": "^5.1.31",
 				"lib0": "^0.2.117",
-				"marked": "^17.0.1",
+				"markdown-it": "15.0.1",
+				"prosemirror-markdown": "1.13.6",
 				"react": "^19.1.1",
 				"react-dom": "^19.1.1",
 				"react-router": "^7.10.0",
-				"sugar-high": "^1.1.0",
+				"tailwind-merge": "^3.6.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",
@@ -41,7 +41,6 @@
 				"@tailwindcss/vite": "^4.1.13",
 				"@testing-library/jest-dom": "^6.9.1",
 				"@testing-library/react": "^16.3.2",
-				"@types/dompurify": "^3.0.5",
 				"@types/node": "^22.19.9",
 				"@types/react": "^19.1.13",
 				"@types/react-dom": "^19.1.9",
@@ -66,21 +65,22 @@
 			"license": "MIT"
 		},
 		"node_modules/@adobe/css-tools": {
-			"version": "4.4.4",
-			"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
-			"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+			"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
 			"dev": true,
 			"license": "MIT"
 		},
 		"node_modules/@ai-sdk/gateway": {
-			"version": "3.0.39",
-			"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.39.tgz",
-			"integrity": "sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==",
+			"version": "3.0.185",
+			"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.185.tgz",
+			"integrity": "sha512-fWY6Gggn9pr9tLWzxsqrfJSoeC9drWKzBckk6Erq9cwiJN3md5ACpskFSYxniYU1wRhNnjbViQVMOoWLqF75MQ==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"dependencies": {
-				"@ai-sdk/provider": "3.0.8",
-				"@ai-sdk/provider-utils": "4.0.14",
-				"@vercel/oidc": "3.1.0"
+				"@ai-sdk/provider": "3.0.15",
+				"@ai-sdk/provider-utils": "4.0.50",
+				"@vercel/oidc": "3.2.0"
 			},
 			"engines": {
 				"node": ">=18"
@@ -90,9 +90,10 @@
 			}
 		},
 		"node_modules/@ai-sdk/provider": {
-			"version": "3.0.8",
-			"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
-			"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
+			"version": "3.0.15",
+			"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.15.tgz",
+			"integrity": "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"dependencies": {
 				"json-schema": "^0.4.0"
@@ -102,17 +103,19 @@
 			}
 		},
 		"node_modules/@ai-sdk/provider-utils": {
-			"version": "4.0.14",
-			"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.14.tgz",
-			"integrity": "sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==",
+			"version": "4.0.50",
+			"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.50.tgz",
+			"integrity": "sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"dependencies": {
-				"@ai-sdk/provider": "3.0.8",
+				"@ai-sdk/provider": "3.0.15",
 				"@standard-schema/spec": "^1.1.0",
-				"eventsource-parser": "^3.0.6"
+				"eventsource-parser": "^3.0.8",
+				"undici": "^6.28.0"
 			},
 			"engines": {
-				"node": ">=18"
+				"node": ">=18.17"
 			},
 			"peerDependencies": {
 				"zod": "^3.25.76 || ^4.1.8"
@@ -122,6 +125,7 @@
 			"version": "11.9.3",
 			"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
 			"integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
+			"license": "MIT",
 			"dependencies": {
 				"@jsdevtools/ono": "^7.1.3",
 				"@types/json-schema": "^7.0.15",
@@ -135,33 +139,26 @@
 			}
 		},
 		"node_modules/@asamuzakjp/css-color": {
-			"version": "4.1.2",
-			"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
-			"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+			"version": "5.1.11",
+			"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+			"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@csstools/css-calc": "^3.0.0",
-				"@csstools/css-color-parser": "^4.0.1",
+				"@asamuzakjp/generational-cache": "^1.0.1",
+				"@csstools/css-calc": "^3.2.0",
+				"@csstools/css-color-parser": "^4.1.0",
 				"@csstools/css-parser-algorithms": "^4.0.0",
-				"@csstools/css-tokenizer": "^4.0.0",
-				"lru-cache": "^11.2.5"
-			}
-		},
-		"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
-			"version": "11.2.5",
-			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
-			"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
-			"dev": true,
-			"license": "BlueOak-1.0.0",
+				"@csstools/css-tokenizer": "^4.0.0"
+			},
 			"engines": {
-				"node": "20 || >=22"
+				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
 			}
 		},
 		"node_modules/@asamuzakjp/dom-selector": {
-			"version": "6.7.8",
-			"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz",
-			"integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==",
+			"version": "6.8.1",
+			"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+			"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
@@ -169,19 +166,29 @@
 				"bidi-js": "^1.0.3",
 				"css-tree": "^3.1.0",
 				"is-potential-custom-element-name": "^1.0.1",
-				"lru-cache": "^11.2.5"
+				"lru-cache": "^11.2.6"
 			}
 		},
 		"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
-			"version": "11.2.5",
-			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
-			"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
+			"version": "11.5.2",
+			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+			"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
 			"dev": true,
 			"license": "BlueOak-1.0.0",
 			"engines": {
 				"node": "20 || >=22"
 			}
 		},
+		"node_modules/@asamuzakjp/generational-cache": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+			"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+			}
+		},
 		"node_modules/@asamuzakjp/nwsapi": {
 			"version": "2.3.9",
 			"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -190,12 +197,13 @@
 			"license": "MIT"
 		},
 		"node_modules/@babel/code-frame": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
-			"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+			"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-validator-identifier": "^7.28.5",
+				"@babel/helper-validator-identifier": "^7.29.7",
 				"js-tokens": "^4.0.0",
 				"picocolors": "^1.1.1"
 			},
@@ -204,29 +212,31 @@
 			}
 		},
 		"node_modules/@babel/compat-data": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
-			"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+			"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/core": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
-			"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
-			"dev": true,
-			"dependencies": {
-				"@babel/code-frame": "^7.29.0",
-				"@babel/generator": "^7.29.0",
-				"@babel/helper-compilation-targets": "^7.28.6",
-				"@babel/helper-module-transforms": "^7.28.6",
-				"@babel/helpers": "^7.28.6",
-				"@babel/parser": "^7.29.0",
-				"@babel/template": "^7.28.6",
-				"@babel/traverse": "^7.29.0",
-				"@babel/types": "^7.29.0",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+			"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@babel/code-frame": "^7.29.7",
+				"@babel/generator": "^7.29.7",
+				"@babel/helper-compilation-targets": "^7.29.7",
+				"@babel/helper-module-transforms": "^7.29.7",
+				"@babel/helpers": "^7.29.7",
+				"@babel/parser": "^7.29.7",
+				"@babel/template": "^7.29.7",
+				"@babel/traverse": "^7.29.7",
+				"@babel/types": "^7.29.7",
 				"@jridgewell/remapping": "^2.3.5",
 				"convert-source-map": "^2.0.0",
 				"debug": "^4.1.0",
@@ -247,18 +257,20 @@
 			"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
 			"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
 			"dev": true,
+			"license": "ISC",
 			"bin": {
 				"semver": "bin/semver.js"
 			}
 		},
 		"node_modules/@babel/generator": {
-			"version": "7.29.1",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
-			"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+			"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/parser": "^7.29.0",
-				"@babel/types": "^7.29.0",
+				"@babel/parser": "^7.29.8",
+				"@babel/types": "^7.29.8",
 				"@jridgewell/gen-mapping": "^0.3.12",
 				"@jridgewell/trace-mapping": "^0.3.28",
 				"jsesc": "^3.0.2"
@@ -268,25 +280,27 @@
 			}
 		},
 		"node_modules/@babel/helper-annotate-as-pure": {
-			"version": "7.27.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
-			"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+			"integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/types": "^7.27.3"
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-compilation-targets": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
-			"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+			"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/compat-data": "^7.28.6",
-				"@babel/helper-validator-option": "^7.27.1",
+				"@babel/compat-data": "^7.29.7",
+				"@babel/helper-validator-option": "^7.29.7",
 				"browserslist": "^4.24.0",
 				"lru-cache": "^5.1.1",
 				"semver": "^6.3.1"
@@ -300,22 +314,24 @@
 			"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
 			"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
 			"dev": true,
+			"license": "ISC",
 			"bin": {
 				"semver": "bin/semver.js"
 			}
 		},
 		"node_modules/@babel/helper-create-class-features-plugin": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
-			"integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+			"integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-annotate-as-pure": "^7.27.3",
-				"@babel/helper-member-expression-to-functions": "^7.28.5",
-				"@babel/helper-optimise-call-expression": "^7.27.1",
-				"@babel/helper-replace-supers": "^7.28.6",
-				"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
-				"@babel/traverse": "^7.28.6",
+				"@babel/helper-annotate-as-pure": "^7.29.7",
+				"@babel/helper-member-expression-to-functions": "^7.29.7",
+				"@babel/helper-optimise-call-expression": "^7.29.7",
+				"@babel/helper-replace-supers": "^7.29.7",
+				"@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+				"@babel/traverse": "^7.29.7",
 				"semver": "^6.3.1"
 			},
 			"engines": {
@@ -330,54 +346,59 @@
 			"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
 			"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
 			"dev": true,
+			"license": "ISC",
 			"bin": {
 				"semver": "bin/semver.js"
 			}
 		},
 		"node_modules/@babel/helper-globals": {
-			"version": "7.28.0",
-			"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
-			"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+			"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-member-expression-to-functions": {
-			"version": "7.28.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
-			"integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+			"integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/traverse": "^7.28.5",
-				"@babel/types": "^7.28.5"
+				"@babel/traverse": "^7.29.7",
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-module-imports": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
-			"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+			"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/traverse": "^7.28.6",
-				"@babel/types": "^7.28.6"
+				"@babel/traverse": "^7.29.7",
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-module-transforms": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
-			"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+			"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-module-imports": "^7.28.6",
-				"@babel/helper-validator-identifier": "^7.28.5",
-				"@babel/traverse": "^7.28.6"
+				"@babel/helper-module-imports": "^7.29.7",
+				"@babel/helper-validator-identifier": "^7.29.7",
+				"@babel/traverse": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -387,35 +408,38 @@
 			}
 		},
 		"node_modules/@babel/helper-optimise-call-expression": {
-			"version": "7.27.1",
-			"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
-			"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+			"integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/types": "^7.27.1"
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-plugin-utils": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
-			"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+			"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-replace-supers": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
-			"integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+			"integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-member-expression-to-functions": "^7.28.5",
-				"@babel/helper-optimise-call-expression": "^7.27.1",
-				"@babel/traverse": "^7.28.6"
+				"@babel/helper-member-expression-to-functions": "^7.29.7",
+				"@babel/helper-optimise-call-expression": "^7.29.7",
+				"@babel/traverse": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -425,65 +449,71 @@
 			}
 		},
 		"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
-			"version": "7.27.1",
-			"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
-			"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+			"integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/traverse": "^7.27.1",
-				"@babel/types": "^7.27.1"
+				"@babel/traverse": "^7.29.7",
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-string-parser": {
-			"version": "7.27.1",
-			"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
-			"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+			"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-validator-identifier": {
-			"version": "7.28.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
-			"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+			"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helper-validator-option": {
-			"version": "7.27.1",
-			"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
-			"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+			"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/helpers": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
-			"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+			"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/template": "^7.28.6",
-				"@babel/types": "^7.28.6"
+				"@babel/template": "^7.29.7",
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/parser": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
-			"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+			"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/types": "^7.29.0"
+				"@babel/types": "^7.29.8"
 			},
 			"bin": {
 				"parser": "bin/babel-parser.js"
@@ -493,12 +523,13 @@
 			}
 		},
 		"node_modules/@babel/plugin-syntax-jsx": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
-			"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz",
+			"integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.28.6"
+				"@babel/helper-plugin-utils": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -508,12 +539,13 @@
 			}
 		},
 		"node_modules/@babel/plugin-syntax-typescript": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
-			"integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz",
+			"integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.28.6"
+				"@babel/helper-plugin-utils": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -523,13 +555,14 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-modules-commonjs": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
-			"integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+			"integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-module-transforms": "^7.28.6",
-				"@babel/helper-plugin-utils": "^7.28.6"
+				"@babel/helper-module-transforms": "^7.29.7",
+				"@babel/helper-plugin-utils": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -539,16 +572,17 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-typescript": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
-			"integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
+			"integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-annotate-as-pure": "^7.27.3",
-				"@babel/helper-create-class-features-plugin": "^7.28.6",
-				"@babel/helper-plugin-utils": "^7.28.6",
-				"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
-				"@babel/plugin-syntax-typescript": "^7.28.6"
+				"@babel/helper-annotate-as-pure": "^7.29.7",
+				"@babel/helper-create-class-features-plugin": "^7.29.7",
+				"@babel/helper-plugin-utils": "^7.29.7",
+				"@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+				"@babel/plugin-syntax-typescript": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -558,16 +592,17 @@
 			}
 		},
 		"node_modules/@babel/preset-typescript": {
-			"version": "7.28.5",
-			"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz",
-			"integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
+			"integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.27.1",
-				"@babel/helper-validator-option": "^7.27.1",
-				"@babel/plugin-syntax-jsx": "^7.27.1",
-				"@babel/plugin-transform-modules-commonjs": "^7.27.1",
-				"@babel/plugin-transform-typescript": "^7.28.5"
+				"@babel/helper-plugin-utils": "^7.29.7",
+				"@babel/helper-validator-option": "^7.29.7",
+				"@babel/plugin-syntax-jsx": "^7.29.7",
+				"@babel/plugin-transform-modules-commonjs": "^7.29.7",
+				"@babel/plugin-transform-typescript": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -577,17 +612,19 @@
 			}
 		},
 		"node_modules/@babel/runtime": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
-			"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+			"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/runtime-corejs3": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz",
-			"integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz",
+			"integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==",
+			"license": "MIT",
 			"dependencies": {
 				"core-js-pure": "^3.48.0"
 			},
@@ -596,31 +633,33 @@
 			}
 		},
 		"node_modules/@babel/template": {
-			"version": "7.28.6",
-			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
-			"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+			"version": "7.29.7",
+			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+			"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/code-frame": "^7.28.6",
-				"@babel/parser": "^7.28.6",
-				"@babel/types": "^7.28.6"
+				"@babel/code-frame": "^7.29.7",
+				"@babel/parser": "^7.29.7",
+				"@babel/types": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/traverse": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
-			"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+			"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/code-frame": "^7.29.0",
-				"@babel/generator": "^7.29.0",
-				"@babel/helper-globals": "^7.28.0",
-				"@babel/parser": "^7.29.0",
-				"@babel/template": "^7.28.6",
-				"@babel/types": "^7.29.0",
+				"@babel/code-frame": "^7.29.7",
+				"@babel/generator": "^7.29.8",
+				"@babel/helper-globals": "^7.29.7",
+				"@babel/parser": "^7.29.8",
+				"@babel/template": "^7.29.7",
+				"@babel/types": "^7.29.8",
 				"debug": "^4.3.1"
 			},
 			"engines": {
@@ -628,36 +667,113 @@
 			}
 		},
 		"node_modules/@babel/types": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
-			"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+			"version": "7.29.8",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+			"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/helper-string-parser": "^7.27.1",
-				"@babel/helper-validator-identifier": "^7.28.5"
+				"@babel/helper-string-parser": "^7.29.7",
+				"@babel/helper-validator-identifier": "^7.29.7"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
+		"node_modules/@base-ui/react": {
+			"version": "1.7.0",
+			"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.7.0.tgz",
+			"integrity": "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==",
+			"license": "MIT",
+			"dependencies": {
+				"@babel/runtime": "^7.29.2",
+				"@base-ui/utils": "0.3.2",
+				"@floating-ui/react-dom": "^2.1.9",
+				"@floating-ui/utils": "^0.2.12",
+				"use-sync-external-store": "^1.6.0"
+			},
+			"engines": {
+				"node": ">=14.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/mui-org"
+			},
+			"peerDependencies": {
+				"@date-fns/tz": "^1.2.0",
+				"@types/react": "^17 || ^18 || ^19",
+				"date-fns": "^4.0.0",
+				"react": "^17 || ^18 || ^19",
+				"react-dom": "^17 || ^18 || ^19"
+			},
+			"peerDependenciesMeta": {
+				"@date-fns/tz": {
+					"optional": true
+				},
+				"@types/react": {
+					"optional": true
+				},
+				"date-fns": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@base-ui/utils": {
+			"version": "0.3.2",
+			"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz",
+			"integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@babel/runtime": "^7.29.2",
+				"@floating-ui/utils": "^0.2.12",
+				"reselect": "^5.2.0",
+				"use-sync-external-store": "^1.6.0"
+			},
+			"peerDependencies": {
+				"@types/react": "^17 || ^18 || ^19",
+				"react": "^17 || ^18 || ^19",
+				"react-dom": "^17 || ^18 || ^19"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@bcoe/v8-coverage": {
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
 			"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			}
 		},
+		"node_modules/@bramus/specificity": {
+			"version": "2.4.2",
+			"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+			"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"css-tree": "^3.0.0"
+			},
+			"bin": {
+				"specificity": "bin/cli.js"
+			}
+		},
 		"node_modules/@cfworker/json-schema": {
 			"version": "4.1.1",
 			"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
-			"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="
+			"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
+			"license": "MIT"
 		},
 		"node_modules/@cloudflare/ai-chat": {
 			"version": "0.0.6",
 			"resolved": "https://registry.npmjs.org/@cloudflare/ai-chat/-/ai-chat-0.0.6.tgz",
 			"integrity": "sha512-XDJP7ywORzQd17f09hMY1n3+bQsLw+BmXh7HzuWGb7gBBbD23OlQXccuQQqFNBYHi+IfIpe9YdGbHaUGsfCUwA==",
+			"license": "MIT",
 			"peer": true,
 			"peerDependencies": {
 				"agents": "^0.3.10",
@@ -670,6 +786,7 @@
 			"version": "0.0.6",
 			"resolved": "https://registry.npmjs.org/@cloudflare/codemode/-/codemode-0.0.6.tgz",
 			"integrity": "sha512-P8ba7fgyeOOEODgU+lyUj82P89VfslKExsdF6nPGRebIbgiCbbpoLHBmBtdRRsIasVHUhceSazJxIS6dg70yRA==",
+			"license": "MIT",
 			"peer": true,
 			"dependencies": {
 				"zod-to-ts": "^2.0.0"
@@ -681,22 +798,24 @@
 			}
 		},
 		"node_modules/@cloudflare/kv-asset-handler": {
-			"version": "0.4.2",
-			"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
-			"integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==",
+			"version": "0.5.0",
+			"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+			"integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
 			"dev": true,
+			"license": "MIT OR Apache-2.0",
 			"engines": {
-				"node": ">=18.0.0"
+				"node": ">=22.0.0"
 			}
 		},
 		"node_modules/@cloudflare/unenv-preset": {
-			"version": "2.12.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz",
-			"integrity": "sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==",
+			"version": "2.16.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+			"integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
 			"dev": true,
+			"license": "MIT OR Apache-2.0",
 			"peerDependencies": {
 				"unenv": "2.0.0-rc.24",
-				"workerd": "^1.20260115.0"
+				"workerd": ">1.20260305.0 <2.0.0-0"
 			},
 			"peerDependenciesMeta": {
 				"workerd": {
@@ -705,30 +824,36 @@
 			}
 		},
 		"node_modules/@cloudflare/vite-plugin": {
-			"version": "1.23.1",
-			"resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.23.1.tgz",
-			"integrity": "sha512-TnE2+U0xM8QWQBC5SlthtIPyit9j6RD7YB0I61jRj28fU4beBH3zYoNXcmHjnhSVU6Y//gIg2xrGV4jXIvdwXw==",
+			"version": "1.54.2",
+			"resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.2.tgz",
+			"integrity": "sha512-15mH5ARHTkNZAqa5GhedjLAt/MCiGBEo09Hgf82EiWuIm/5yOaMVL6ABSun/W8C3Vbw1clzW/2i9IfH5zT/Kvg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@cloudflare/unenv-preset": "2.12.0",
-				"miniflare": "4.20260205.0",
+				"@cloudflare/unenv-preset": "2.16.1",
+				"miniflare": "5.20260828.0-alpha",
 				"unenv": "2.0.0-rc.24",
-				"wrangler": "4.63.0",
-				"ws": "8.18.0"
+				"workerd": "1.20260828.1",
+				"wrangler": "4.127.1",
+				"ws": "8.21.0"
+			},
+			"bin": {
+				"cf-vite": "bin/cf-vite"
 			},
 			"peerDependencies": {
-				"vite": "^6.1.0 || ^7.0.0",
-				"wrangler": "^4.63.0"
+				"vite": "^6.1.0 || ^7.0.0 || ^8.0.0",
+				"wrangler": "^4.127.1"
 			}
 		},
 		"node_modules/@cloudflare/workerd-darwin-64": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260205.0.tgz",
-			"integrity": "sha512-ToOItqcirmWPwR+PtT+Q4bdjTn/63ZxhJKEfW4FNn7FxMTS1Tw5dml0T0mieOZbCpcvY8BdvPKFCSlJuI8IVHQ==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260828.1.tgz",
+			"integrity": "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -738,13 +863,14 @@
 			}
 		},
 		"node_modules/@cloudflare/workerd-darwin-arm64": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260205.0.tgz",
-			"integrity": "sha512-402ZqLz+LrG0NDXp7Hn7IZbI0DyhjNfjAlVenb0K3yod9KCuux0u3NksNBvqJx0mIGHvVR4K05h+jfT5BTHqGA==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260828.1.tgz",
+			"integrity": "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -754,13 +880,14 @@
 			}
 		},
 		"node_modules/@cloudflare/workerd-linux-64": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260205.0.tgz",
-			"integrity": "sha512-rz9jBzazIA18RHY+osa19hvsPfr0LZI1AJzIjC6UqkKKphcTpHBEQ25Xt8cIA34ivMIqeENpYnnmpDFesLkfcQ==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260828.1.tgz",
+			"integrity": "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -770,13 +897,14 @@
 			}
 		},
 		"node_modules/@cloudflare/workerd-linux-arm64": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260205.0.tgz",
-			"integrity": "sha512-jr6cKpMM/DBEbL+ATJ9rYue758CKp0SfA/nXt5vR32iINVJrb396ye9iat2y9Moa/PgPKnTrFgmT6urUmG3IUg==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260828.1.tgz",
+			"integrity": "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -786,13 +914,14 @@
 			}
 		},
 		"node_modules/@cloudflare/workerd-windows-64": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260205.0.tgz",
-			"integrity": "sha512-SMPW5jCZYOG7XFIglSlsgN8ivcl0pCrSAYxCwxtWvZ88whhcDB/aISNtiQiDZujPH8tIo2hE5dEkxW7tGEwc3A==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260828.1.tgz",
+			"integrity": "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"win32"
@@ -801,17 +930,12 @@
 				"node": ">=16"
 			}
 		},
-		"node_modules/@cloudflare/workers-types": {
-			"version": "4.20260207.0",
-			"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260207.0.tgz",
-			"integrity": "sha512-PSxgnAOK0EtTytlY7/+gJcsQJYg0Qo7KlOMSC/wiBE+pBqKjuKdd1ZgM+NvpPNqZAjWV5jqAMTTNYEmgk27gYw==",
-			"peer": true
-		},
 		"node_modules/@cspotcode/source-map-support": {
 			"version": "0.8.1",
 			"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
 			"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/trace-mapping": "0.3.9"
 			},
@@ -824,15 +948,16 @@
 			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
 			"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/resolve-uri": "^3.0.3",
 				"@jridgewell/sourcemap-codec": "^1.4.10"
 			}
 		},
 		"node_modules/@csstools/color-helpers": {
-			"version": "6.0.1",
-			"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
-			"integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+			"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
 			"dev": true,
 			"funding": [
 				{
@@ -850,9 +975,9 @@
 			}
 		},
 		"node_modules/@csstools/css-calc": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz",
-			"integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==",
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+			"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
 			"dev": true,
 			"funding": [
 				{
@@ -874,9 +999,9 @@
 			}
 		},
 		"node_modules/@csstools/css-color-parser": {
-			"version": "4.0.1",
-			"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
-			"integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
+			"version": "4.2.2",
+			"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
+			"integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
 			"dev": true,
 			"funding": [
 				{
@@ -890,8 +1015,8 @@
 			],
 			"license": "MIT",
 			"dependencies": {
-				"@csstools/color-helpers": "^6.0.1",
-				"@csstools/css-calc": "^3.0.0"
+				"@csstools/color-helpers": "^6.1.1",
+				"@csstools/css-calc": "^3.3.0"
 			},
 			"engines": {
 				"node": ">=20.19.0"
@@ -925,9 +1050,9 @@
 			}
 		},
 		"node_modules/@csstools/css-syntax-patches-for-csstree": {
-			"version": "1.0.26",
-			"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz",
-			"integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==",
+			"version": "1.1.11",
+			"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.11.tgz",
+			"integrity": "sha512-+a7SqvwQfLl8OAp/C/B8PhfV2UjRUIeCRvcZQEXjhTkM2nTxg8r9nbB7mdxkEGT67VbR6pREsAIlsx65N2KR6Q==",
 			"dev": true,
 			"funding": [
 				{
@@ -939,7 +1064,15 @@
 					"url": "https://opencollective.com/csstools"
 				}
 			],
-			"license": "MIT-0"
+			"license": "MIT-0",
+			"peerDependencies": {
+				"css-tree": "^3.2.1"
+			},
+			"peerDependenciesMeta": {
+				"css-tree": {
+					"optional": true
+				}
+			}
 		},
 		"node_modules/@csstools/css-tokenizer": {
 			"version": "4.0.0",
@@ -962,23 +1095,25 @@
 			}
 		},
 		"node_modules/@emnapi/runtime": {
-			"version": "1.8.1",
-			"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
-			"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+			"version": "1.11.3",
+			"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+			"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"dependencies": {
 				"tslib": "^2.4.0"
 			}
 		},
 		"node_modules/@esbuild/aix-ppc64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
-			"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+			"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"aix"
@@ -988,13 +1123,14 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
-			"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+			"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -1004,13 +1140,14 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
-			"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+			"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -1020,13 +1157,14 @@
 			}
 		},
 		"node_modules/@esbuild/android-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
-			"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+			"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -1036,13 +1174,14 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
-			"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+			"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -1052,13 +1191,14 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
-			"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+			"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -1068,13 +1208,14 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
-			"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+			"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
@@ -1084,13 +1225,14 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
-			"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+			"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
@@ -1100,13 +1242,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
-			"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+			"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1116,13 +1259,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
-			"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+			"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1132,13 +1276,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ia32": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
-			"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+			"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1148,13 +1293,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-loong64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
-			"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+			"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
 			"cpu": [
 				"loong64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1164,13 +1310,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-mips64el": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
-			"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+			"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
 			"cpu": [
 				"mips64el"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1180,13 +1327,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ppc64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
-			"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+			"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1196,13 +1344,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-riscv64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
-			"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+			"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1212,13 +1361,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-s390x": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
-			"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+			"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1228,13 +1378,14 @@
 			}
 		},
 		"node_modules/@esbuild/linux-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
-			"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+			"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1244,13 +1395,14 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
-			"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+			"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"netbsd"
@@ -1260,13 +1412,14 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
-			"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+			"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"netbsd"
@@ -1276,13 +1429,14 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
-			"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+			"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
@@ -1292,13 +1446,14 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
-			"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+			"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
@@ -1308,13 +1463,14 @@
 			}
 		},
 		"node_modules/@esbuild/openharmony-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
-			"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+			"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openharmony"
@@ -1324,13 +1480,14 @@
 			}
 		},
 		"node_modules/@esbuild/sunos-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
-			"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+			"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"sunos"
@@ -1340,13 +1497,14 @@
 			}
 		},
 		"node_modules/@esbuild/win32-arm64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
-			"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+			"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -1356,13 +1514,14 @@
 			}
 		},
 		"node_modules/@esbuild/win32-ia32": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
-			"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+			"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -1372,13 +1531,14 @@
 			}
 		},
 		"node_modules/@esbuild/win32-x64": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
-			"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+			"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -1388,10 +1548,11 @@
 			}
 		},
 		"node_modules/@eslint-community/eslint-utils": {
-			"version": "4.9.1",
-			"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
-			"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+			"version": "4.10.1",
+			"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+			"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"eslint-visitor-keys": "^3.4.3"
 			},
@@ -1410,6 +1571,7 @@
 			"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
 			"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
 			},
@@ -1422,19 +1584,21 @@
 			"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
 			"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
 			}
 		},
 		"node_modules/@eslint/config-array": {
-			"version": "0.21.1",
-			"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
-			"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+			"version": "0.21.2",
+			"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+			"integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"dependencies": {
 				"@eslint/object-schema": "^2.1.7",
 				"debug": "^4.3.1",
-				"minimatch": "^3.1.2"
+				"minimatch": "^3.1.5"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1445,6 +1609,7 @@
 			"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
 			"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"dependencies": {
 				"@eslint/core": "^0.17.0"
 			},
@@ -1457,6 +1622,7 @@
 			"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
 			"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"dependencies": {
 				"@types/json-schema": "^7.0.15"
 			},
@@ -1465,19 +1631,20 @@
 			}
 		},
 		"node_modules/@eslint/eslintrc": {
-			"version": "3.3.3",
-			"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
-			"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+			"version": "3.3.6",
+			"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
+			"integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"ajv": "^6.12.4",
+				"ajv": "^6.14.0",
 				"debug": "^4.3.2",
 				"espree": "^10.0.1",
 				"globals": "^14.0.0",
 				"ignore": "^5.2.0",
 				"import-fresh": "^3.2.1",
-				"js-yaml": "^4.1.1",
-				"minimatch": "^3.1.2",
+				"js-yaml": "^4.3.0",
+				"minimatch": "^3.1.5",
 				"strip-json-comments": "^3.1.1"
 			},
 			"engines": {
@@ -1488,10 +1655,11 @@
 			}
 		},
 		"node_modules/@eslint/eslintrc/node_modules/ajv": {
-			"version": "6.12.6",
-			"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-			"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+			"version": "6.15.0",
+			"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+			"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"fast-deep-equal": "^3.1.1",
 				"fast-json-stable-stringify": "^2.0.0",
@@ -1507,13 +1675,15 @@
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
 			"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@eslint/js": {
-			"version": "9.39.2",
-			"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
-			"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+			"version": "9.39.5",
+			"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+			"integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
@@ -1526,6 +1696,7 @@
 			"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
 			"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			}
@@ -1535,6 +1706,7 @@
 			"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
 			"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"dependencies": {
 				"@eslint/core": "^0.17.0",
 				"levn": "^0.4.1"
@@ -1544,9 +1716,9 @@
 			}
 		},
 		"node_modules/@exodus/bytes": {
-			"version": "1.12.0",
-			"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz",
-			"integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==",
+			"version": "1.15.1",
+			"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+			"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
 			"dev": true,
 			"license": "MIT",
 			"engines": {
@@ -1562,31 +1734,31 @@
 			}
 		},
 		"node_modules/@floating-ui/core": {
-			"version": "1.7.4",
-			"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
-			"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
+			"version": "1.8.0",
+			"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+			"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@floating-ui/utils": "^0.2.10"
+				"@floating-ui/utils": "^0.2.12"
 			}
 		},
 		"node_modules/@floating-ui/dom": {
-			"version": "1.7.5",
-			"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
-			"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
+			"version": "1.8.0",
+			"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+			"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
 			"license": "MIT",
 			"dependencies": {
-				"@floating-ui/core": "^1.7.4",
-				"@floating-ui/utils": "^0.2.10"
+				"@floating-ui/core": "^1.8.0",
+				"@floating-ui/utils": "^0.2.12"
 			}
 		},
 		"node_modules/@floating-ui/react-dom": {
-			"version": "2.1.7",
-			"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
-			"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
+			"version": "2.1.9",
+			"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
+			"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
 			"license": "MIT",
 			"dependencies": {
-				"@floating-ui/dom": "^1.7.5"
+				"@floating-ui/dom": "^1.8.0"
 			},
 			"peerDependencies": {
 				"react": ">=16.8.0",
@@ -1594,15 +1766,16 @@
 			}
 		},
 		"node_modules/@floating-ui/utils": {
-			"version": "0.2.10",
-			"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
-			"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+			"version": "0.2.12",
+			"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+			"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
 			"license": "MIT"
 		},
 		"node_modules/@hono/node-server": {
-			"version": "1.19.9",
-			"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
-			"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+			"version": "1.19.17",
+			"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
+			"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18.14.1"
 			},
@@ -1611,32 +1784,49 @@
 			}
 		},
 		"node_modules/@humanfs/core": {
-			"version": "0.19.1",
-			"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
-			"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+			"version": "0.19.2",
+			"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+			"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
 			"dev": true,
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@humanfs/types": "^0.15.0"
+			},
 			"engines": {
 				"node": ">=18.18.0"
 			}
 		},
 		"node_modules/@humanfs/node": {
-			"version": "0.16.7",
-			"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
-			"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+			"version": "0.16.8",
+			"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+			"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"dependencies": {
-				"@humanfs/core": "^0.19.1",
+				"@humanfs/core": "^0.19.2",
+				"@humanfs/types": "^0.15.0",
 				"@humanwhocodes/retry": "^0.4.0"
 			},
 			"engines": {
 				"node": ">=18.18.0"
 			}
 		},
+		"node_modules/@humanfs/types": {
+			"version": "0.15.0",
+			"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+			"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=18.18.0"
+			}
+		},
 		"node_modules/@humanwhocodes/module-importer": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
 			"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=12.22"
 			},
@@ -1650,6 +1840,7 @@
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
 			"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=18.18"
 			},
@@ -1659,66 +1850,90 @@
 			}
 		},
 		"node_modules/@img/colour": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
-			"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+			"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			}
 		},
 		"node_modules/@img/sharp-darwin-arm64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
-			"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
+			"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-darwin-arm64": "1.2.4"
+				"@img/sharp-libvips-darwin-arm64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-darwin-x64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
-			"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
+			"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-darwin-x64": "1.2.4"
+				"@img/sharp-libvips-darwin-x64": "1.3.1"
+			}
+		},
+		"node_modules/@img/sharp-freebsd-wasm32": {
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
+			"integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"optional": true,
+			"os": [
+				"freebsd"
+			],
+			"dependencies": {
+				"@img/sharp-wasm32": "0.35.2"
+			},
+			"engines": {
+				"node": ">=20.9.0"
+			},
+			"funding": {
+				"url": "https://opencollective.com/libvips"
 			}
 		},
 		"node_modules/@img/sharp-libvips-darwin-arm64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
-			"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
+			"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -1728,13 +1943,14 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-darwin-x64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
-			"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
+			"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -1744,13 +1960,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-arm": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
-			"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
+			"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1760,13 +1980,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-arm64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
-			"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
+			"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1776,13 +2000,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-ppc64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
-			"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
+			"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1792,13 +2020,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-riscv64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
-			"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
+			"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1808,13 +2040,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-s390x": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
-			"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
+			"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1824,13 +2060,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linux-x64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
-			"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
+			"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1840,13 +2080,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
-			"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
+			"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1856,13 +2100,17 @@
 			}
 		},
 		"node_modules/@img/sharp-libvips-linuxmusl-x64": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
-			"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+			"version": "1.3.1",
+			"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
+			"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"linux"
@@ -1872,252 +2120,305 @@
 			}
 		},
 		"node_modules/@img/sharp-linux-arm": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
-			"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
+			"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-arm": "1.2.4"
+				"@img/sharp-libvips-linux-arm": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linux-arm64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
-			"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
+			"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-arm64": "1.2.4"
+				"@img/sharp-libvips-linux-arm64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linux-ppc64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
-			"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
+			"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-ppc64": "1.2.4"
+				"@img/sharp-libvips-linux-ppc64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linux-riscv64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
-			"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
+			"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-riscv64": "1.2.4"
+				"@img/sharp-libvips-linux-riscv64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linux-s390x": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
-			"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
+			"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-s390x": "1.2.4"
+				"@img/sharp-libvips-linux-s390x": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linux-x64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
-			"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
+			"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linux-x64": "1.2.4"
+				"@img/sharp-libvips-linux-x64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linuxmusl-arm64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
-			"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
+			"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+				"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-linuxmusl-x64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
-			"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
+			"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "Apache-2.0",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+				"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
 			}
 		},
 		"node_modules/@img/sharp-wasm32": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
-			"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
+			"integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+			"dev": true,
+			"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+			"optional": true,
+			"dependencies": {
+				"@emnapi/runtime": "^1.11.1"
+			},
+			"engines": {
+				"node": ">=20.9.0"
+			},
+			"funding": {
+				"url": "https://opencollective.com/libvips"
+			}
+		},
+		"node_modules/@img/sharp-webcontainers-wasm32": {
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
+			"integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
 			"cpu": [
 				"wasm32"
 			],
 			"dev": true,
+			"license": "Apache-2.0",
 			"optional": true,
 			"dependencies": {
-				"@emnapi/runtime": "^1.7.0"
+				"@img/sharp-wasm32": "0.35.2"
 			},
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			}
 		},
 		"node_modules/@img/sharp-win32-arm64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
-			"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
+			"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "Apache-2.0 AND LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			}
 		},
 		"node_modules/@img/sharp-win32-ia32": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
-			"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
+			"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "Apache-2.0 AND LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": "^20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			}
 		},
 		"node_modules/@img/sharp-win32-x64": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
-			"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
+			"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "Apache-2.0 AND LGPL-3.0-or-later",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
@@ -2128,6 +2429,7 @@
 			"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
 			"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/sourcemap-codec": "^1.5.0",
 				"@jridgewell/trace-mapping": "^0.3.24"
@@ -2138,6 +2440,7 @@
 			"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
 			"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/gen-mapping": "^0.3.5",
 				"@jridgewell/trace-mapping": "^0.3.24"
@@ -2148,21 +2451,24 @@
 			"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
 			"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.0.0"
 			}
 		},
 		"node_modules/@jridgewell/sourcemap-codec": {
-			"version": "1.5.5",
-			"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
-			"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
-			"dev": true
+			"version": "1.6.0",
+			"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+			"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@jridgewell/trace-mapping": {
 			"version": "0.3.31",
 			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
 			"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/resolve-uri": "^3.1.0",
 				"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -2171,18 +2477,21 @@
 		"node_modules/@jsdevtools/ono": {
 			"version": "7.1.3",
 			"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
-			"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
+			"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
+			"license": "MIT"
 		},
 		"node_modules/@mjackson/node-fetch-server": {
 			"version": "0.2.0",
 			"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
 			"integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@modelcontextprotocol/sdk": {
 			"version": "1.25.2",
 			"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
 			"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
+			"license": "MIT",
 			"dependencies": {
 				"@hono/node-server": "^1.19.7",
 				"ajv": "^8.17.1",
@@ -2217,10 +2526,31 @@
 				}
 			}
 		},
+		"node_modules/@napi-rs/lzma-linux-x64-gnu": {
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+			"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": "^22.20 || ^24.12 || >=25"
+			}
+		},
 		"node_modules/@opentelemetry/api": {
-			"version": "1.9.0",
-			"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
-			"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+			"version": "1.9.1",
+			"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+			"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"engines": {
 				"node": ">=8.0.0"
@@ -2231,6 +2561,7 @@
 			"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
 			"integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"kleur": "^4.1.5"
 			}
@@ -2240,6 +2571,7 @@
 			"resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
 			"integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@poppinss/colors": "^4.1.5",
 				"@sindresorhus/is": "^7.0.2",
@@ -2251,6 +2583,7 @@
 			"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
 			"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -2262,590 +2595,15 @@
 			"version": "1.2.3",
 			"resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
 			"integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
-			"dev": true
-		},
-		"node_modules/@radix-ui/primitive": {
-			"version": "1.1.3",
-			"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
-			"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
-			"license": "MIT"
-		},
-		"node_modules/@radix-ui/react-arrow": {
-			"version": "1.1.7",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
-			"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-primitive": "2.1.3"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-collection": {
-			"version": "1.1.7",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
-			"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-slot": "1.2.3"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-compose-refs": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
-			"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-context": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
-			"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-direction": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
-			"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-dismissable-layer": {
-			"version": "1.1.11",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
-			"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/primitive": "1.1.3",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-callback-ref": "1.1.1",
-				"@radix-ui/react-use-escape-keydown": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-dropdown-menu": {
-			"version": "2.1.16",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
-			"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/primitive": "1.1.3",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-id": "1.1.1",
-				"@radix-ui/react-menu": "2.1.16",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-controllable-state": "1.2.2"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-focus-guards": {
-			"version": "1.1.3",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
-			"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-focus-scope": {
-			"version": "1.1.7",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
-			"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-callback-ref": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-id": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
-			"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-menu": {
-			"version": "2.1.16",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
-			"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/primitive": "1.1.3",
-				"@radix-ui/react-collection": "1.1.7",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-direction": "1.1.1",
-				"@radix-ui/react-dismissable-layer": "1.1.11",
-				"@radix-ui/react-focus-guards": "1.1.3",
-				"@radix-ui/react-focus-scope": "1.1.7",
-				"@radix-ui/react-id": "1.1.1",
-				"@radix-ui/react-popper": "1.2.8",
-				"@radix-ui/react-portal": "1.1.9",
-				"@radix-ui/react-presence": "1.1.5",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-roving-focus": "1.1.11",
-				"@radix-ui/react-slot": "1.2.3",
-				"@radix-ui/react-use-callback-ref": "1.1.1",
-				"aria-hidden": "^1.2.4",
-				"react-remove-scroll": "^2.6.3"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-popper": {
-			"version": "1.2.8",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
-			"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
-			"license": "MIT",
-			"dependencies": {
-				"@floating-ui/react-dom": "^2.0.0",
-				"@radix-ui/react-arrow": "1.1.7",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-callback-ref": "1.1.1",
-				"@radix-ui/react-use-layout-effect": "1.1.1",
-				"@radix-ui/react-use-rect": "1.1.1",
-				"@radix-ui/react-use-size": "1.1.1",
-				"@radix-ui/rect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-portal": {
-			"version": "1.1.9",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
-			"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-presence": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
-			"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-primitive": {
-			"version": "2.1.3",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
-			"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-slot": "1.2.3"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-roving-focus": {
-			"version": "1.1.11",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
-			"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/primitive": "1.1.3",
-				"@radix-ui/react-collection": "1.1.7",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-direction": "1.1.1",
-				"@radix-ui/react-id": "1.1.1",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-callback-ref": "1.1.1",
-				"@radix-ui/react-use-controllable-state": "1.2.2"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-slot": {
-			"version": "1.2.3",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
-			"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.2"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-switch": {
-			"version": "1.2.6",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
-			"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/primitive": "1.1.3",
-				"@radix-ui/react-compose-refs": "1.1.2",
-				"@radix-ui/react-context": "1.1.2",
-				"@radix-ui/react-primitive": "2.1.3",
-				"@radix-ui/react-use-controllable-state": "1.2.2",
-				"@radix-ui/react-use-previous": "1.1.1",
-				"@radix-ui/react-use-size": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"@types/react-dom": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-callback-ref": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
-			"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-controllable-state": {
-			"version": "1.2.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
-			"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-use-effect-event": "0.0.2",
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-effect-event": {
-			"version": "0.0.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
-			"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-escape-keydown": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
-			"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-use-callback-ref": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-layout-effect": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
-			"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-previous": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
-			"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
-			"license": "MIT",
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-rect": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
-			"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/rect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-use-size": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
-			"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-use-layout-effect": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/rect": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
-			"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+			"dev": true,
 			"license": "MIT"
 		},
 		"node_modules/@react-router/dev": {
-			"version": "7.13.0",
-			"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.13.0.tgz",
-			"integrity": "sha512-0vRfTrS6wIXr9j0STu614Cv2ytMr21evnv1r+DXPv5cJ4q0V2x2kBAXC8TAqEXkpN5vdhbXBlbGQ821zwOfhvg==",
+			"version": "7.18.3",
+			"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz",
+			"integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@babel/core": "^7.27.7",
 				"@babel/generator": "^7.27.5",
@@ -2854,7 +2612,7 @@
 				"@babel/preset-typescript": "^7.27.1",
 				"@babel/traverse": "^7.27.7",
 				"@babel/types": "^7.27.7",
-				"@react-router/node": "7.13.0",
+				"@react-router/node": "7.18.3",
 				"@remix-run/node-fetch-server": "^0.13.0",
 				"arg": "^5.0.1",
 				"babel-dead-code-elimination": "^1.0.6",
@@ -2883,12 +2641,12 @@
 				"node": ">=20.0.0"
 			},
 			"peerDependencies": {
-				"@react-router/serve": "^7.13.0",
-				"@vitejs/plugin-rsc": "~0.5.7",
-				"react-router": "^7.13.0",
+				"@react-router/serve": "^7.18.3",
+				"@vitejs/plugin-rsc": "~0.5.21",
+				"react-router": "^7.18.3",
 				"react-server-dom-webpack": "^19.2.3",
-				"typescript": "^5.1.0",
-				"vite": "^5.1.0 || ^6.0.0 || ^7.0.0",
+				"typescript": "^5.1.0 || ^6.0.0",
+				"vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
 				"wrangler": "^3.28.2 || ^4.0.0"
 			},
 			"peerDependenciesMeta": {
@@ -2910,10 +2668,11 @@
 			}
 		},
 		"node_modules/@react-router/node": {
-			"version": "7.13.0",
-			"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.13.0.tgz",
-			"integrity": "sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A==",
+			"version": "7.18.3",
+			"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz",
+			"integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@mjackson/node-fetch-server": "^0.2.0"
 			},
@@ -2921,8 +2680,8 @@
 				"node": ">=20.0.0"
 			},
 			"peerDependencies": {
-				"react-router": "7.13.0",
-				"typescript": "^5.1.0"
+				"react-router": "7.18.3",
+				"typescript": "^5.1.0 || ^6.0.0"
 			},
 			"peerDependenciesMeta": {
 				"typescript": {
@@ -2930,338 +2689,397 @@
 				}
 			}
 		},
-		"node_modules/@remirror/core-constants": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
-			"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
-			"license": "MIT"
-		},
 		"node_modules/@remix-run/node-fetch-server": {
-			"version": "0.13.0",
-			"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.0.tgz",
-			"integrity": "sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==",
-			"dev": true
+			"version": "0.13.3",
+			"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz",
+			"integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@rollup/rollup-android-arm-eabi": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
-			"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+			"integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			]
 		},
 		"node_modules/@rollup/rollup-android-arm64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
-			"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+			"integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			]
 		},
 		"node_modules/@rollup/rollup-darwin-arm64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
-			"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+			"integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			]
 		},
 		"node_modules/@rollup/rollup-darwin-x64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
-			"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+			"integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			]
 		},
 		"node_modules/@rollup/rollup-freebsd-arm64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
-			"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+			"integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
 			]
 		},
 		"node_modules/@rollup/rollup-freebsd-x64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
-			"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+			"integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
-			"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+			"integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-arm-musleabihf": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
-			"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+			"integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-arm64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
-			"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+			"integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-arm64-musl": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
-			"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+			"integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-loong64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
-			"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+			"integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
 			"cpu": [
 				"loong64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-loong64-musl": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
-			"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+			"integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
 			"cpu": [
 				"loong64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-ppc64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
-			"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+			"integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-ppc64-musl": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
-			"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+			"integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-riscv64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
-			"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+			"integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-riscv64-musl": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
-			"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+			"integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-s390x-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
-			"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+			"integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-x64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
-			"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+			"integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-linux-x64-musl": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
-			"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+			"integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			]
 		},
 		"node_modules/@rollup/rollup-openbsd-x64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
-			"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+			"integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
 			]
 		},
 		"node_modules/@rollup/rollup-openharmony-arm64": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
-			"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+			"integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openharmony"
 			]
 		},
 		"node_modules/@rollup/rollup-win32-arm64-msvc": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
-			"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+			"integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			]
 		},
 		"node_modules/@rollup/rollup-win32-ia32-msvc": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
-			"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+			"integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			]
 		},
 		"node_modules/@rollup/rollup-win32-x64-gnu": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
-			"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+			"integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			]
 		},
 		"node_modules/@rollup/rollup-win32-x64-msvc": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
-			"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+			"integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -3272,6 +3090,7 @@
 			"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
 			"integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -3280,202 +3099,227 @@
 			}
 		},
 		"node_modules/@speed-highlight/core": {
-			"version": "1.2.14",
-			"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
-			"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
-			"dev": true
+			"version": "1.2.24",
+			"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+			"integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+			"dev": true,
+			"license": "CC0-1.0"
 		},
 		"node_modules/@standard-schema/spec": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
-			"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
+			"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+			"license": "MIT"
 		},
 		"node_modules/@tailwindcss/node": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
-			"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+			"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@jridgewell/remapping": "^2.3.4",
-				"enhanced-resolve": "^5.18.3",
-				"jiti": "^2.6.1",
-				"lightningcss": "1.30.2",
+				"@jridgewell/remapping": "^2.3.5",
+				"enhanced-resolve": "^5.24.1",
+				"jiti": "^2.7.0",
+				"lightningcss": "1.32.0",
 				"magic-string": "^0.30.21",
 				"source-map-js": "^1.2.1",
-				"tailwindcss": "4.1.18"
+				"tailwindcss": "4.3.3"
 			}
 		},
 		"node_modules/@tailwindcss/oxide": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
-			"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+			"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			},
 			"optionalDependencies": {
-				"@tailwindcss/oxide-android-arm64": "4.1.18",
-				"@tailwindcss/oxide-darwin-arm64": "4.1.18",
-				"@tailwindcss/oxide-darwin-x64": "4.1.18",
-				"@tailwindcss/oxide-freebsd-x64": "4.1.18",
-				"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
-				"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
-				"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
-				"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
-				"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
-				"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
-				"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
-				"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+				"@tailwindcss/oxide-android-arm64": "4.3.3",
+				"@tailwindcss/oxide-darwin-arm64": "4.3.3",
+				"@tailwindcss/oxide-darwin-x64": "4.3.3",
+				"@tailwindcss/oxide-freebsd-x64": "4.3.3",
+				"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+				"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+				"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+				"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+				"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+				"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+				"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+				"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-android-arm64": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
-			"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+			"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-darwin-arm64": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
-			"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+			"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-darwin-x64": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
-			"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+			"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-freebsd-x64": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
-			"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+			"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
-			"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+			"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
-			"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+			"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
-			"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+			"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
-			"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+			"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-linux-x64-musl": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
-			"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+			"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-wasm32-wasi": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
-			"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+			"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
 			"bundleDependencies": [
 				"@napi-rs/wasm-runtime",
 				"@emnapi/core",
@@ -3488,63 +3332,67 @@
 				"wasm32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"dependencies": {
-				"@emnapi/core": "^1.7.1",
-				"@emnapi/runtime": "^1.7.1",
-				"@emnapi/wasi-threads": "^1.1.0",
-				"@napi-rs/wasm-runtime": "^1.1.0",
-				"@tybys/wasm-util": "^0.10.1",
-				"tslib": "^2.4.0"
+				"@emnapi/core": "^1.11.1",
+				"@emnapi/runtime": "^1.11.1",
+				"@emnapi/wasi-threads": "^1.2.2",
+				"@napi-rs/wasm-runtime": "^1.1.4",
+				"@tybys/wasm-util": "^0.10.2",
+				"tslib": "^2.8.1"
 			},
 			"engines": {
 				"node": ">=14.0.0"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
-			"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+			"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
-			"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+			"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
 			],
 			"engines": {
-				"node": ">= 10"
+				"node": ">= 20"
 			}
 		},
 		"node_modules/@tailwindcss/vite": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
-			"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+			"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@tailwindcss/node": "4.1.18",
-				"@tailwindcss/oxide": "4.1.18",
-				"tailwindcss": "4.1.18"
+				"@tailwindcss/node": "4.3.3",
+				"@tailwindcss/oxide": "4.3.3",
+				"tailwindcss": "4.3.3"
 			},
 			"peerDependencies": {
-				"vite": "^5.2.0 || ^6 || ^7"
+				"vite": "^5.2.0 || ^6 || ^7 || ^8"
 			}
 		},
 		"node_modules/@testing-library/dom": {
@@ -3564,205 +3412,440 @@
 				"picocolors": "1.1.1",
 				"pretty-format": "^27.0.2"
 			},
-			"engines": {
-				"node": ">=18"
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@testing-library/jest-dom": {
+			"version": "6.9.1",
+			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+			"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@adobe/css-tools": "^4.4.0",
+				"aria-query": "^5.0.0",
+				"css.escape": "^1.5.1",
+				"dom-accessibility-api": "^0.6.3",
+				"picocolors": "^1.1.1",
+				"redent": "^3.0.0"
+			},
+			"engines": {
+				"node": ">=14",
+				"npm": ">=6",
+				"yarn": ">=1"
+			}
+		},
+		"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+			"version": "0.6.3",
+			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+			"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@testing-library/react": {
+			"version": "16.3.3",
+			"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+			"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@babel/runtime": "^7.12.5"
+			},
+			"engines": {
+				"node": ">=18"
+			},
+			"peerDependencies": {
+				"@testing-library/dom": "^10.0.0",
+				"@types/react": "^18.0.0 || ^19.0.0",
+				"@types/react-dom": "^18.0.0 || ^19.0.0",
+				"react": "^18.0.0 || ^19.0.0",
+				"react-dom": "^18.0.0 || ^19.0.0"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@tiptap/core": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.5.tgz",
+			"integrity": "sha512-3O7N0FyKIfuLV+xrdWyDM3V5eUY/q2CgLjhhMwOAbM1Pu7VPp9VP+TpEYOdH8aRyB+h1vj5hX5A747D8ZrPfHA==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/pm": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-blockquote": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.5.tgz",
+			"integrity": "sha512-8pf1ZDrl6XlVPUee/2YlWZPFSF7yqsJEX6WLW41x06MT1dapG3Qudcns0znj6kYsX8JXTJ+Mhegy4z19bvTtRg==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-bold": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.5.tgz",
+			"integrity": "sha512-MLZS+s/BJiJbv2C2M3G4FQGn43kPwYUUr2TiW3afSn+dRkEZlDxx5CwZYbe1PEt2+VX/SIifn945Oqr3s0axSA==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-bubble-menu": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.5.tgz",
+			"integrity": "sha512-redcmBInVwmipjF/WEVcNwCUJgJygUv8leidfRb/jBSAwx7GgnQWPr2HVQDk9zeTt3ykWld2NRdrk94Crjymow==",
+			"license": "MIT",
+			"dependencies": {
+				"@floating-ui/dom": "^1.0.0"
+			},
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-bullet-list": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.5.tgz",
+			"integrity": "sha512-66OGw4suO0Gr/4QAEiSvVi0eSvEqStvu1moHUSvUlwvEqnuB0ME2w9HuRogPElzWdrYLzbBA47mlCi9Veva0ew==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/extension-list": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-code": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.5.tgz",
+			"integrity": "sha512-0dCt8eBo4sMtw+LjUu+0GF0JrTlREROdWoy8TM5kFPxJ5ZU2LKDiROXDPwXStaaoFFR/aStLH9xSpdz7cHUiUA==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-code-block": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.5.tgz",
+			"integrity": "sha512-SvkNoOio2xBy9AtSMyFKMp88MTUvyIXsGCnuz71INV2wHdH4VfRPoQLT6e077LpTCa9w6LHyTKtbZA4HYOp7wg==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-collaboration": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.30.5.tgz",
+			"integrity": "sha512-LIw2JCPhAI7mWEfaPzct2WYAmSMuv4QPRQqX6AGtg6EcqLUj2N39ds6kBg5jgC7g8vGUNs8/lj5C/9SW0YrDyw==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5",
+				"@tiptap/y-tiptap": "^3.0.7",
+				"yjs": "^13"
+			}
+		},
+		"node_modules/@tiptap/extension-collaboration-caret": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.30.5.tgz",
+			"integrity": "sha512-mWCoxhuwb30SPdHvp6o9CV0cvH27gbvE0LwmdVjmiHg0lqN1WTA6cT73xwHZEYOxF/eYtggjFwsySSP5LNZKTg==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5",
+				"@tiptap/y-tiptap": "^3.0.7"
+			}
+		},
+		"node_modules/@tiptap/extension-document": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.5.tgz",
+			"integrity": "sha512-4mKoD3bBr5W2AQ2Y/7amOqcVw0eqJbUwRtwvxypeo5Hi0PB9O5Ev8P05c3EUTo10bllqdKYXGeCU5AcgdQsHWQ==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-dropcursor": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.5.tgz",
+			"integrity": "sha512-gJxn9PMUee8zazQBYqbOZiI7zTFXPVJ15AzTnLfUu4eDtPieMwtpgcjhCt4bZlp37drLm+Li76wWLirb7iLxsw==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/extensions": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-gapcursor": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.5.tgz",
+			"integrity": "sha512-h3m2ZA1XXLAfdHwdtG0MZnf0KWbNyP8xTI3RUlTDR5apmKmYVBaeocOJwTcFFZ92gPKqxYKyhteZqYtHOCZGJA==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/extensions": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-hard-break": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.5.tgz",
+			"integrity": "sha512-PcL4Z8l/DlauwZUJg0jf6SXKk6/YOXJtd8yYzqHrmCLRoY4kAa7+TxUhc/lWhuitUiOZ8+nB8V/NNM+WR6mRaw==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-heading": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.5.tgz",
+			"integrity": "sha512-x7e7+p1bWXvwz6vqONPP1SVB73V0gZP7LIDL/5KMnxWLjahMTabgVPgjV30kz7GsOEgUYtEf7eCyGVGNNWL5Dg==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-horizontal-rule": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.5.tgz",
+			"integrity": "sha512-s5t2xM6wPYRJl2cqYplc7Ze8m8xddk4DP2v0aLFcUuD100D1B5eJbxsiwLK6wBAzNwiuVjd486RnmUpcswaXhQ==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
 			}
 		},
-		"node_modules/@testing-library/jest-dom": {
-			"version": "6.9.1",
-			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
-			"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
-			"dev": true,
+		"node_modules/@tiptap/extension-italic": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.5.tgz",
+			"integrity": "sha512-Fu9EuSRlHQNGES7UhY4mQod3yUKnWwxsGlPuDJURfwju/Ug04Jz8iRuERZBPcRgLNkwKDmbDsWUZF72RRIL2Uw==",
 			"license": "MIT",
-			"dependencies": {
-				"@adobe/css-tools": "^4.4.0",
-				"aria-query": "^5.0.0",
-				"css.escape": "^1.5.1",
-				"dom-accessibility-api": "^0.6.3",
-				"picocolors": "^1.1.1",
-				"redent": "^3.0.0"
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
 			},
-			"engines": {
-				"node": ">=14",
-				"npm": ">=6",
-				"yarn": ">=1"
+			"peerDependencies": {
+				"@tiptap/core": "3.30.5"
 			}
 		},
-		"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
-			"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
-			"dev": true,
-			"license": "MIT"
-		},
-		"node_modules/@testing-library/react": {
-			"version": "16.3.2",
-			"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
-			"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
-			"dev": true,
+		"node_modules/@tiptap/extension-link": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.5.tgz",
+			"integrity": "sha512-zsQ+q83HpCYOMTNzdjb12dggE7sZMp0aqQJhArdavSTl8hLbVY/u3qJN88t25IEwAKVK0pcTJfWWA2QFl8bjAQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/runtime": "^7.12.5"
+				"linkifyjs": "^4.3.3"
 			},
-			"engines": {
-				"node": ">=18"
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@testing-library/dom": "^10.0.0",
-				"@types/react": "^18.0.0 || ^19.0.0",
-				"@types/react-dom": "^18.0.0 || ^19.0.0",
-				"react": "^18.0.0 || ^19.0.0",
-				"react-dom": "^18.0.0 || ^19.0.0"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				},
-				"@types/react-dom": {
-					"optional": true
-				}
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/core": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
-			"integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==",
+		"node_modules/@tiptap/extension-list": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.5.tgz",
+			"integrity": "sha512-CHThihH+7TA0TfwtmA+eTXP1MAsA/+p011olJHg8Rilj5xA+L+0IFkjhN16JnZkBgSA4MnfEv15UMNfRaNpuVg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/pm": "^3.19.0"
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-bubble-menu": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.19.0.tgz",
-			"integrity": "sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==",
+		"node_modules/@tiptap/extension-list-item": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.5.tgz",
+			"integrity": "sha512-N0fKUyQkPQBvVB48imjfIfdexU9VsAX1RydajYP2xAt+QOJ8KHVmXv1EGTp+v7pGdZMLdls1cSZ2f352vW3aWQ==",
 			"license": "MIT",
-			"dependencies": {
-				"@floating-ui/dom": "^1.0.0"
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
 			},
+			"peerDependencies": {
+				"@tiptap/extension-list": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/extension-list-keymap": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.5.tgz",
+			"integrity": "sha512-3pLR2yo29uhowaGMbD8v71XpgdNmqotBg1NXlyTMVFzxELj+VjjeLLTqSguR/iWDUbHXNzIsfMEgpRnNMO//8w==",
+			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/pm": "^3.19.0"
+				"@tiptap/extension-list": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-collaboration": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.19.0.tgz",
-			"integrity": "sha512-Cb4RXo2C05w44OsT22weLYqf2mnyTacvtz3iWYswgq1slMOl4Gs5RQE+jHgyvjVbhj34yPS6ghoWBBrriX9a1w==",
+		"node_modules/@tiptap/extension-ordered-list": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.5.tgz",
+			"integrity": "sha512-bUGUnSAgjZhoUWBtr+1wRJHF3NnNvuKQr6sQ8le1tJ2yvLq448ZsSZjZGvTNeLsy3GDKBoMJ0rqLUK34+nM3hg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/pm": "^3.19.0",
-				"@tiptap/y-tiptap": "^3.0.2",
-				"yjs": "^13"
+				"@tiptap/extension-list": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-collaboration-caret": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.19.0.tgz",
-			"integrity": "sha512-YMHQ7ZxdWaGzR+k0UWP7eHo+jrUbrx2C3gTEGYpR2YMGN38yuV5Yelwzd2rflaedccJrFsYwMWxRRs5ygTGGUw==",
+		"node_modules/@tiptap/extension-paragraph": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.5.tgz",
+			"integrity": "sha512-GrNNlAImfQhYRtGE95YNAjcTclUUMPHs9JeE9vMgfcoKcOmZ6GsbWUdN0hA55xqwGaUjnroOVtf77K9z1EbkJQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/pm": "^3.19.0",
-				"@tiptap/y-tiptap": "^3.0.2"
+				"@tiptap/core": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-document": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.19.0.tgz",
-			"integrity": "sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==",
+		"node_modules/@tiptap/extension-strike": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.5.tgz",
+			"integrity": "sha512-r8IbWUm4YXNCkmc6h6dzuREQIGbO0x9z9l3GfQPUR3LxFtGssDLngE4dFXHRt/ejLWKEauy6clklf7LawtvYzw==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0"
+				"@tiptap/core": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-floating-menu": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.19.0.tgz",
-			"integrity": "sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==",
+		"node_modules/@tiptap/extension-text": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.5.tgz",
+			"integrity": "sha512-pOgj4mIGFlw4NUdA6PCTFP/MHrYWnOYiZRYeu0I3/Yo3iN4Am/CgYBLl8PWsNh+YPO2hA2DWs7ZMke9D2j5R+g==",
 			"license": "MIT",
-			"optional": true,
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@floating-ui/dom": "^1.0.0",
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/pm": "^3.19.0"
+				"@tiptap/core": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-paragraph": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.19.0.tgz",
-			"integrity": "sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==",
+		"node_modules/@tiptap/extension-underline": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.5.tgz",
+			"integrity": "sha512-u12G/WW2uFRY95BzrSAU9RW002K+7lFpSCL6fb7Puh8yLhek3lddOtAx0P+NI/PyMKtcoVMtBxoi+0mwVM7NPQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0"
+				"@tiptap/core": "3.30.5"
 			}
 		},
-		"node_modules/@tiptap/extension-text": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.19.0.tgz",
-			"integrity": "sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==",
+		"node_modules/@tiptap/extensions": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.5.tgz",
+			"integrity": "sha512-5x3OiCYBvXz0G5OGM8f7ka++1dh9EXdNRZ8IcMcEd+QwW4RPwvmJ2EjheA1eRRyMlcbNU7T+4IRlBXURlTetEA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0"
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
 			}
 		},
 		"node_modules/@tiptap/pm": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz",
-			"integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==",
-			"license": "MIT",
-			"dependencies": {
-				"prosemirror-changeset": "^2.3.0",
-				"prosemirror-collab": "^1.3.1",
-				"prosemirror-commands": "^1.6.2",
-				"prosemirror-dropcursor": "^1.8.1",
-				"prosemirror-gapcursor": "^1.3.2",
-				"prosemirror-history": "^1.4.1",
-				"prosemirror-inputrules": "^1.4.0",
-				"prosemirror-keymap": "^1.2.2",
-				"prosemirror-markdown": "^1.13.1",
-				"prosemirror-menu": "^1.2.4",
-				"prosemirror-model": "^1.24.1",
-				"prosemirror-schema-basic": "^1.2.3",
-				"prosemirror-schema-list": "^1.5.0",
-				"prosemirror-state": "^1.4.3",
-				"prosemirror-tables": "^1.6.4",
-				"prosemirror-trailing-node": "^3.0.0",
-				"prosemirror-transform": "^1.10.2",
-				"prosemirror-view": "^1.38.1"
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.5.tgz",
+			"integrity": "sha512-gufkLkW2tA6PZPjivYxDiGzTIIftwqhmYI6lvvKu2S4FbhcysJgMAe/GXVSywzCRVVex9SrvCe6RFYrqnwRitQ==",
+			"license": "MIT",
+			"dependencies": {
+				"prosemirror-changeset": "^2.4.1",
+				"prosemirror-commands": "^1.7.1",
+				"prosemirror-dropcursor": "^1.8.2",
+				"prosemirror-gapcursor": "^1.4.1",
+				"prosemirror-history": "^1.5.0",
+				"prosemirror-inputrules": "^1.5.1",
+				"prosemirror-keymap": "^1.2.3",
+				"prosemirror-model": "^1.25.11",
+				"prosemirror-schema-list": "^1.5.1",
+				"prosemirror-state": "^1.4.4",
+				"prosemirror-tables": "^1.8.5",
+				"prosemirror-transform": "^1.12.0",
+				"prosemirror-view": "^1.41.9"
 			},
 			"funding": {
 				"type": "github",
@@ -3770,9 +3853,9 @@
 			}
 		},
 		"node_modules/@tiptap/react": {
-			"version": "3.19.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.19.0.tgz",
-			"integrity": "sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==",
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.30.5.tgz",
+			"integrity": "sha512-QtHdOmYTCMRkCf5dmLtIqFq0e/yxavkQ6ZXbtytoftWEKJf3MjBTlX4nVAgpvrLAjPmCTxiehEXXxD1AJm/Syw==",
 			"license": "MIT",
 			"dependencies": {
 				"@types/use-sync-external-store": "^0.0.6",
@@ -3784,22 +3867,74 @@
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"optionalDependencies": {
-				"@tiptap/extension-bubble-menu": "^3.19.0",
-				"@tiptap/extension-floating-menu": "^3.19.0"
+				"@tiptap/extension-bubble-menu": "^3.30.5",
+				"@tiptap/extension-floating-menu": "^3.30.5"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.19.0",
-				"@tiptap/pm": "^3.19.0",
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5",
 				"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
 				"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
 				"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
 				"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
 			}
 		},
+		"node_modules/@tiptap/react/node_modules/@tiptap/extension-floating-menu": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.5.tgz",
+			"integrity": "sha512-gTjGPWUpGn8IoW533TciZJZC/81LmBgU7zee+aRMRF3ix3K2z1pJjl6knDvrRQA6Kom+yWFaiueqGNXkrk38XQ==",
+			"license": "MIT",
+			"optional": true,
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@floating-ui/dom": "^1.0.0",
+				"@tiptap/core": "3.30.5",
+				"@tiptap/pm": "3.30.5"
+			}
+		},
+		"node_modules/@tiptap/starter-kit": {
+			"version": "3.30.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.5.tgz",
+			"integrity": "sha512-cmDRvukpoqjQjhE96q+l3tCLNZZcJKehxK+00Sp9F4XbKecfM2QpZ2TRHh3vi8qhCeDQNyMoCqfNaqznIjLjIQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@tiptap/core": "3.30.5",
+				"@tiptap/extension-blockquote": "3.30.5",
+				"@tiptap/extension-bold": "3.30.5",
+				"@tiptap/extension-bullet-list": "3.30.5",
+				"@tiptap/extension-code": "3.30.5",
+				"@tiptap/extension-code-block": "3.30.5",
+				"@tiptap/extension-document": "3.30.5",
+				"@tiptap/extension-dropcursor": "3.30.5",
+				"@tiptap/extension-gapcursor": "3.30.5",
+				"@tiptap/extension-hard-break": "3.30.5",
+				"@tiptap/extension-heading": "3.30.5",
+				"@tiptap/extension-horizontal-rule": "3.30.5",
+				"@tiptap/extension-italic": "3.30.5",
+				"@tiptap/extension-link": "3.30.5",
+				"@tiptap/extension-list": "3.30.5",
+				"@tiptap/extension-list-item": "3.30.5",
+				"@tiptap/extension-list-keymap": "3.30.5",
+				"@tiptap/extension-ordered-list": "3.30.5",
+				"@tiptap/extension-paragraph": "3.30.5",
+				"@tiptap/extension-strike": "3.30.5",
+				"@tiptap/extension-text": "3.30.5",
+				"@tiptap/extension-underline": "3.30.5",
+				"@tiptap/extensions": "3.30.5",
+				"@tiptap/pm": "3.30.5"
+			},
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			}
+		},
 		"node_modules/@tiptap/y-tiptap": {
-			"version": "3.0.2",
-			"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz",
-			"integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==",
+			"version": "3.0.9",
+			"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.9.tgz",
+			"integrity": "sha512-7/El8NQ8R5V5MkdrOUdfj9IgZacpt0H071xNimX7B0AnYiWiKefQnMKd41neQYzo2MOXbWdN3iZ+7Z7BruzOSA==",
 			"license": "MIT",
 			"dependencies": {
 				"lib0": "^0.2.100"
@@ -3829,6 +3964,7 @@
 			"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
 			"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@types/deep-eql": "*",
 				"assertion-error": "^2.0.1"
@@ -3838,28 +3974,21 @@
 			"version": "4.0.2",
 			"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
 			"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
-			"dev": true
-		},
-		"node_modules/@types/dompurify": {
-			"version": "3.0.5",
-			"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
-			"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
 			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"@types/trusted-types": "*"
-			}
+			"license": "MIT"
 		},
 		"node_modules/@types/estree": {
-			"version": "1.0.8",
-			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
-			"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
-			"dev": true
+			"version": "1.0.9",
+			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+			"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@types/json-schema": {
 			"version": "7.0.15",
 			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
-			"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
+			"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+			"license": "MIT"
 		},
 		"node_modules/@types/linkify-it": {
 			"version": "5.0.0",
@@ -3868,14 +3997,15 @@
 			"license": "MIT"
 		},
 		"node_modules/@types/lodash": {
-			"version": "4.17.23",
-			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
-			"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="
+			"version": "4.17.25",
+			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+			"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+			"license": "MIT"
 		},
 		"node_modules/@types/markdown-it": {
-			"version": "14.1.2",
-			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
-			"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+			"version": "14.2.0",
+			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
+			"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
 			"license": "MIT",
 			"dependencies": {
 				"@types/linkify-it": "^5",
@@ -3889,37 +4019,33 @@
 			"license": "MIT"
 		},
 		"node_modules/@types/node": {
-			"version": "22.19.9",
-			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
-			"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
+			"version": "22.20.1",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+			"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"undici-types": "~6.21.0"
 			}
 		},
 		"node_modules/@types/react": {
-			"version": "19.2.13",
-			"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
-			"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
+			"version": "19.2.18",
+			"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+			"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+			"license": "MIT",
 			"dependencies": {
 				"csstype": "^3.2.2"
 			}
 		},
 		"node_modules/@types/react-dom": {
-			"version": "19.2.3",
-			"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
-			"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+			"version": "19.2.5",
+			"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+			"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+			"license": "MIT",
 			"peerDependencies": {
 				"@types/react": "^19.2.0"
 			}
 		},
-		"node_modules/@types/trusted-types": {
-			"version": "2.0.7",
-			"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
-			"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
-			"devOptional": true,
-			"license": "MIT"
-		},
 		"node_modules/@types/use-sync-external-store": {
 			"version": "0.0.6",
 			"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -3927,19 +4053,20 @@
 			"license": "MIT"
 		},
 		"node_modules/@typescript-eslint/eslint-plugin": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
-			"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
+			"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@eslint-community/regexpp": "^4.12.2",
-				"@typescript-eslint/scope-manager": "8.54.0",
-				"@typescript-eslint/type-utils": "8.54.0",
-				"@typescript-eslint/utils": "8.54.0",
-				"@typescript-eslint/visitor-keys": "8.54.0",
+				"@typescript-eslint/scope-manager": "8.68.0",
+				"@typescript-eslint/type-utils": "8.68.0",
+				"@typescript-eslint/utils": "8.68.0",
+				"@typescript-eslint/visitor-keys": "8.68.0",
 				"ignore": "^7.0.5",
 				"natural-compare": "^1.4.0",
-				"ts-api-utils": "^2.4.0"
+				"ts-api-utils": "^2.5.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3949,30 +4076,32 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"@typescript-eslint/parser": "^8.54.0",
-				"eslint": "^8.57.0 || ^9.0.0",
-				"typescript": ">=4.8.4 <6.0.0"
+				"@typescript-eslint/parser": "^8.68.0",
+				"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
-			"version": "7.0.5",
-			"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
-			"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+			"version": "7.0.8",
+			"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
+			"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">= 4"
 			}
 		},
 		"node_modules/@typescript-eslint/parser": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
-			"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
+			"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/scope-manager": "8.54.0",
-				"@typescript-eslint/types": "8.54.0",
-				"@typescript-eslint/typescript-estree": "8.54.0",
-				"@typescript-eslint/visitor-keys": "8.54.0",
+				"@typescript-eslint/scope-manager": "8.68.0",
+				"@typescript-eslint/types": "8.68.0",
+				"@typescript-eslint/typescript-estree": "8.68.0",
+				"@typescript-eslint/visitor-keys": "8.68.0",
 				"debug": "^4.4.3"
 			},
 			"engines": {
@@ -3983,18 +4112,19 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^8.57.0 || ^9.0.0",
-				"typescript": ">=4.8.4 <6.0.0"
+				"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/project-service": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
-			"integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
+			"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/tsconfig-utils": "^8.54.0",
-				"@typescript-eslint/types": "^8.54.0",
+				"@typescript-eslint/tsconfig-utils": "^8.68.0",
+				"@typescript-eslint/types": "^8.68.0",
 				"debug": "^4.4.3"
 			},
 			"engines": {
@@ -4005,17 +4135,18 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"typescript": ">=4.8.4 <6.0.0"
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/scope-manager": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
-			"integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
+			"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "8.54.0",
-				"@typescript-eslint/visitor-keys": "8.54.0"
+				"@typescript-eslint/types": "8.68.0",
+				"@typescript-eslint/visitor-keys": "8.68.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4026,10 +4157,11 @@
 			}
 		},
 		"node_modules/@typescript-eslint/tsconfig-utils": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
-			"integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
+			"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
@@ -4038,20 +4170,21 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"typescript": ">=4.8.4 <6.0.0"
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/type-utils": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz",
-			"integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
+			"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "8.54.0",
-				"@typescript-eslint/typescript-estree": "8.54.0",
-				"@typescript-eslint/utils": "8.54.0",
+				"@typescript-eslint/types": "8.68.0",
+				"@typescript-eslint/typescript-estree": "8.68.0",
+				"@typescript-eslint/utils": "8.68.0",
 				"debug": "^4.4.3",
-				"ts-api-utils": "^2.4.0"
+				"ts-api-utils": "^2.5.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4061,15 +4194,16 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^8.57.0 || ^9.0.0",
-				"typescript": ">=4.8.4 <6.0.0"
+				"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/types": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
-			"integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
+			"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
@@ -4079,20 +4213,21 @@
 			}
 		},
 		"node_modules/@typescript-eslint/typescript-estree": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
-			"integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
+			"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/project-service": "8.54.0",
-				"@typescript-eslint/tsconfig-utils": "8.54.0",
-				"@typescript-eslint/types": "8.54.0",
-				"@typescript-eslint/visitor-keys": "8.54.0",
+				"@typescript-eslint/project-service": "8.68.0",
+				"@typescript-eslint/tsconfig-utils": "8.68.0",
+				"@typescript-eslint/types": "8.68.0",
+				"@typescript-eslint/visitor-keys": "8.68.0",
 				"debug": "^4.4.3",
-				"minimatch": "^9.0.5",
+				"minimatch": "^10.2.2",
 				"semver": "^7.7.3",
 				"tinyglobby": "^0.2.15",
-				"ts-api-utils": "^2.4.0"
+				"ts-api-utils": "^2.5.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4102,43 +4237,59 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"typescript": ">=4.8.4 <6.0.0"
+				"typescript": ">=4.8.4 <6.1.0"
+			}
+		},
+		"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+			"version": "4.0.4",
+			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+			"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": "18 || 20 || >=22"
 			}
 		},
 		"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
-			"version": "2.0.2",
-			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-			"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+			"version": "5.0.9",
+			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+			"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"balanced-match": "^1.0.0"
+				"balanced-match": "^4.0.2"
+			},
+			"engines": {
+				"node": "20 || >=22"
 			}
 		},
 		"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
-			"version": "9.0.5",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-			"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+			"version": "10.2.6",
+			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+			"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
 			"dev": true,
+			"license": "BlueOak-1.0.0",
 			"dependencies": {
-				"brace-expansion": "^2.0.1"
+				"brace-expansion": "^5.0.8"
 			},
 			"engines": {
-				"node": ">=16 || 14 >=14.17"
+				"node": "18 || 20 || >=22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/isaacs"
 			}
 		},
 		"node_modules/@typescript-eslint/utils": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
-			"integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
+			"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@eslint-community/eslint-utils": "^4.9.1",
-				"@typescript-eslint/scope-manager": "8.54.0",
-				"@typescript-eslint/types": "8.54.0",
-				"@typescript-eslint/typescript-estree": "8.54.0"
+				"@typescript-eslint/scope-manager": "8.68.0",
+				"@typescript-eslint/types": "8.68.0",
+				"@typescript-eslint/typescript-estree": "8.68.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4148,18 +4299,19 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^8.57.0 || ^9.0.0",
-				"typescript": ">=4.8.4 <6.0.0"
+				"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/@typescript-eslint/visitor-keys": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
-			"integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
+			"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "8.54.0",
-				"eslint-visitor-keys": "^4.2.1"
+				"@typescript-eslint/types": "8.68.0",
+				"eslint-visitor-keys": "^5.0.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4169,38 +4321,53 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			}
 		},
+		"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+			"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"engines": {
+				"node": "^20.19.0 || ^22.13.0 || >=24"
+			},
+			"funding": {
+				"url": "https://opencollective.com/eslint"
+			}
+		},
 		"node_modules/@vercel/oidc": {
-			"version": "3.1.0",
-			"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
-			"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
+			"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"engines": {
 				"node": ">= 20"
 			}
 		},
 		"node_modules/@vitest/coverage-v8": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
-			"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+			"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@bcoe/v8-coverage": "^1.0.2",
-				"@vitest/utils": "4.0.18",
-				"ast-v8-to-istanbul": "^0.3.10",
+				"@vitest/utils": "4.1.11",
+				"ast-v8-to-istanbul": "^1.0.0",
 				"istanbul-lib-coverage": "^3.2.2",
 				"istanbul-lib-report": "^3.0.1",
 				"istanbul-reports": "^3.2.0",
-				"magicast": "^0.5.1",
+				"magicast": "^0.5.2",
 				"obug": "^2.1.1",
-				"std-env": "^3.10.0",
-				"tinyrainbow": "^3.0.3"
+				"std-env": "^4.0.0-rc.1",
+				"tinyrainbow": "^3.1.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/vitest"
 			},
 			"peerDependencies": {
-				"@vitest/browser": "4.0.18",
-				"vitest": "4.0.18"
+				"@vitest/browser": "4.1.11",
+				"vitest": "4.1.11"
 			},
 			"peerDependenciesMeta": {
 				"@vitest/browser": {
@@ -4209,29 +4376,31 @@
 			}
 		},
 		"node_modules/@vitest/expect": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
-			"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+			"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@standard-schema/spec": "^1.0.0",
+				"@standard-schema/spec": "^1.1.0",
 				"@types/chai": "^5.2.2",
-				"@vitest/spy": "4.0.18",
-				"@vitest/utils": "4.0.18",
-				"chai": "^6.2.1",
-				"tinyrainbow": "^3.0.3"
+				"@vitest/spy": "4.1.11",
+				"@vitest/utils": "4.1.11",
+				"chai": "^6.2.2",
+				"tinyrainbow": "^3.1.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/vitest"
 			}
 		},
 		"node_modules/@vitest/mocker": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
-			"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+			"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@vitest/spy": "4.0.18",
+				"@vitest/spy": "4.1.11",
 				"estree-walker": "^3.0.3",
 				"magic-string": "^0.30.21"
 			},
@@ -4240,7 +4409,7 @@
 			},
 			"peerDependencies": {
 				"msw": "^2.4.9",
-				"vite": "^6.0.0 || ^7.0.0-0"
+				"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
 			},
 			"peerDependenciesMeta": {
 				"msw": {
@@ -4252,24 +4421,26 @@
 			}
 		},
 		"node_modules/@vitest/pretty-format": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
-			"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+			"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"tinyrainbow": "^3.0.3"
+				"tinyrainbow": "^3.1.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/vitest"
 			}
 		},
 		"node_modules/@vitest/runner": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
-			"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+			"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@vitest/utils": "4.0.18",
+				"@vitest/utils": "4.1.11",
 				"pathe": "^2.0.3"
 			},
 			"funding": {
@@ -4280,15 +4451,18 @@
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@vitest/snapshot": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
-			"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+			"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@vitest/pretty-format": "4.0.18",
+				"@vitest/pretty-format": "4.1.11",
+				"@vitest/utils": "4.1.11",
 				"magic-string": "^0.30.21",
 				"pathe": "^2.0.3"
 			},
@@ -4300,25 +4474,29 @@
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/@vitest/spy": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
-			"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+			"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
 			"dev": true,
+			"license": "MIT",
 			"funding": {
 				"url": "https://opencollective.com/vitest"
 			}
 		},
 		"node_modules/@vitest/utils": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
-			"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+			"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@vitest/pretty-format": "4.0.18",
-				"tinyrainbow": "^3.0.3"
+				"@vitest/pretty-format": "4.1.11",
+				"convert-source-map": "^2.0.0",
+				"tinyrainbow": "^3.1.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/vitest"
@@ -4328,6 +4506,7 @@
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
 			"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+			"license": "MIT",
 			"dependencies": {
 				"mime-types": "^3.0.0",
 				"negotiator": "^1.0.0"
@@ -4337,10 +4516,11 @@
 			}
 		},
 		"node_modules/acorn": {
-			"version": "8.15.0",
-			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
-			"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+			"version": "8.18.0",
+			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+			"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
 			"dev": true,
+			"license": "MIT",
 			"bin": {
 				"acorn": "bin/acorn"
 			},
@@ -4353,6 +4533,7 @@
 			"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
 			"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
 			"dev": true,
+			"license": "MIT",
 			"peerDependencies": {
 				"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
 			}
@@ -4371,6 +4552,7 @@
 			"version": "0.3.10",
 			"resolved": "https://registry.npmjs.org/agents/-/agents-0.3.10.tgz",
 			"integrity": "sha512-hKj3nbej14GA2SgE6/stQheJF35LCS7DxMuHG0QDl1/npnnECYyG0Yf5DXl6AvJAZOFNDwT3iEzKi8yLZngPDA==",
+			"license": "MIT",
 			"dependencies": {
 				"@cfworker/json-schema": "^4.1.1",
 				"@modelcontextprotocol/sdk": "1.25.2",
@@ -4413,16 +4595,36 @@
 				}
 			}
 		},
+		"node_modules/agents/node_modules/@cloudflare/workers-types": {
+			"version": "4.20260702.1",
+			"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz",
+			"integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==",
+			"license": "MIT OR Apache-2.0",
+			"peer": true
+		},
+		"node_modules/agents/node_modules/partyserver": {
+			"version": "0.1.5",
+			"resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.1.5.tgz",
+			"integrity": "sha512-kaE3GYaYWFc70EJQDQEhyYbO2Wczz/NgsFXerfjRo0t2s7ZxL1XggWT+HkMrdEyqbZOv3b66CV93WG0Lcg/ThQ==",
+			"license": "ISC",
+			"dependencies": {
+				"nanoid": "^5.1.6"
+			},
+			"peerDependencies": {
+				"@cloudflare/workers-types": "^4.20240729.0"
+			}
+		},
 		"node_modules/ai": {
-			"version": "6.0.77",
-			"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.77.tgz",
-			"integrity": "sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==",
+			"version": "6.0.272",
+			"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.272.tgz",
+			"integrity": "sha512-GSyvGAKp3U1hl6sGMLbdKiLQLPCzewCZGZtY1SmjPUcPbmwn2IjWQgoT+2c3DRLcsvfY0ifuw+8Dy5N/QFd7Ew==",
+			"license": "Apache-2.0",
 			"peer": true,
 			"dependencies": {
-				"@ai-sdk/gateway": "3.0.39",
-				"@ai-sdk/provider": "3.0.8",
-				"@ai-sdk/provider-utils": "4.0.14",
-				"@opentelemetry/api": "1.9.0"
+				"@ai-sdk/gateway": "3.0.185",
+				"@ai-sdk/provider": "3.0.15",
+				"@ai-sdk/provider-utils": "4.0.50",
+				"@opentelemetry/api": "^1.9.0"
 			},
 			"engines": {
 				"node": ">=18"
@@ -4432,9 +4634,10 @@
 			}
 		},
 		"node_modules/ajv": {
-			"version": "8.17.1",
-			"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
-			"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+			"version": "8.20.0",
+			"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+			"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+			"license": "MIT",
 			"dependencies": {
 				"fast-deep-equal": "^3.1.3",
 				"fast-uri": "^3.0.1",
@@ -4450,6 +4653,7 @@
 			"version": "3.0.1",
 			"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
 			"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+			"license": "MIT",
 			"dependencies": {
 				"ajv": "^8.0.0"
 			},
@@ -4463,14 +4667,14 @@
 			}
 		},
 		"node_modules/ansi-regex": {
-			"version": "6.2.2",
-			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
-			"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+			"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+			"dev": true,
+			"license": "MIT",
+			"peer": true,
 			"engines": {
-				"node": ">=12"
-			},
-			"funding": {
-				"url": "https://github.com/chalk/ansi-regex?sponsor=1"
+				"node": ">=8"
 			}
 		},
 		"node_modules/ansi-styles": {
@@ -4478,6 +4682,7 @@
 			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
 			"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"color-convert": "^2.0.1"
 			},
@@ -4492,24 +4697,14 @@
 			"version": "5.0.2",
 			"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
 			"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/argparse": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
-		},
-		"node_modules/aria-hidden": {
-			"version": "1.2.6",
-			"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
-			"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
-			"license": "MIT",
-			"dependencies": {
-				"tslib": "^2.0.0"
-			},
-			"engines": {
-				"node": ">=10"
-			}
+			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+			"license": "Python-2.0"
 		},
 		"node_modules/aria-query": {
 			"version": "5.3.0",
@@ -4526,15 +4721,17 @@
 			"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
 			"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=12"
 			}
 		},
 		"node_modules/ast-v8-to-istanbul": {
-			"version": "0.3.11",
-			"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
-			"integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+			"version": "1.0.5",
+			"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+			"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/trace-mapping": "^0.3.31",
 				"estree-walker": "^3.0.3",
@@ -4545,13 +4742,15 @@
 			"version": "10.0.0",
 			"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
 			"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/babel-dead-code-elimination": {
 			"version": "1.0.12",
 			"resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
 			"integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@babel/core": "^7.23.7",
 				"@babel/parser": "^7.23.6",
@@ -4563,15 +4762,20 @@
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
 			"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/baseline-browser-mapping": {
-			"version": "2.9.19",
-			"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
-			"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+			"version": "2.11.20",
+			"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+			"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"bin": {
-				"baseline-browser-mapping": "dist/cli.js"
+				"baseline-browser-mapping": "dist/cli.cjs"
+			},
+			"engines": {
+				"node": ">=6.0.0"
 			}
 		},
 		"node_modules/bidi-js": {
@@ -4588,22 +4792,24 @@
 			"version": "2.1.5",
 			"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
 			"integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/body-parser": {
-			"version": "2.2.2",
-			"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
-			"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+			"version": "2.3.0",
+			"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+			"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+			"license": "MIT",
 			"dependencies": {
 				"bytes": "^3.1.2",
-				"content-type": "^1.0.5",
+				"content-type": "^2.0.0",
 				"debug": "^4.4.3",
-				"http-errors": "^2.0.0",
-				"iconv-lite": "^0.7.0",
+				"http-errors": "^2.0.1",
+				"iconv-lite": "^0.7.2",
 				"on-finished": "^2.4.1",
-				"qs": "^6.14.1",
-				"raw-body": "^3.0.1",
-				"type-is": "^2.0.1"
+				"qs": "^6.15.2",
+				"raw-body": "^3.0.2",
+				"type-is": "^2.1.0"
 			},
 			"engines": {
 				"node": ">=18"
@@ -4613,20 +4819,34 @@
 				"url": "https://opencollective.com/express"
 			}
 		},
+		"node_modules/body-parser/node_modules/content-type": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+			"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=18"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
+			}
+		},
 		"node_modules/brace-expansion": {
-			"version": "1.1.12",
-			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-			"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+			"version": "1.1.18",
+			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+			"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"balanced-match": "^1.0.0",
 				"concat-map": "0.0.1"
 			}
 		},
 		"node_modules/browserslist": {
-			"version": "4.28.1",
-			"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
-			"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+			"version": "4.28.8",
+			"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+			"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
 			"dev": true,
 			"funding": [
 				{
@@ -4642,12 +4862,13 @@
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"dependencies": {
-				"baseline-browser-mapping": "^2.9.0",
-				"caniuse-lite": "^1.0.30001759",
-				"electron-to-chromium": "^1.5.263",
-				"node-releases": "^2.0.27",
-				"update-browserslist-db": "^1.2.0"
+				"baseline-browser-mapping": "^2.11.12",
+				"caniuse-lite": "^1.0.30001809",
+				"electron-to-chromium": "^1.5.402",
+				"node-releases": "^2.0.53",
+				"update-browserslist-db": "^1.3.0"
 			},
 			"bin": {
 				"browserslist": "cli.js"
@@ -4660,6 +4881,7 @@
 			"version": "3.1.2",
 			"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
 			"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
@@ -4669,6 +4891,7 @@
 			"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
 			"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			}
@@ -4677,6 +4900,7 @@
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
 			"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+			"license": "MIT",
 			"dependencies": {
 				"es-errors": "^1.3.0",
 				"function-bind": "^1.1.2"
@@ -4689,6 +4913,7 @@
 			"version": "1.0.4",
 			"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
 			"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+			"license": "MIT",
 			"dependencies": {
 				"call-bind-apply-helpers": "^1.0.2",
 				"get-intrinsic": "^1.3.0"
@@ -4705,14 +4930,15 @@
 			"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
 			"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			}
 		},
 		"node_modules/caniuse-lite": {
-			"version": "1.0.30001769",
-			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
-			"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
+			"version": "1.0.30001810",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+			"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
 			"dev": true,
 			"funding": [
 				{
@@ -4727,13 +4953,15 @@
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
-			]
+			],
+			"license": "CC-BY-4.0"
 		},
 		"node_modules/chai": {
 			"version": "6.2.2",
 			"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
 			"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			}
@@ -4743,6 +4971,7 @@
 			"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
 			"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"ansi-styles": "^4.1.0",
 				"supports-color": "^7.1.0"
@@ -4759,6 +4988,7 @@
 			"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
 			"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"readdirp": "^4.0.1"
 			},
@@ -4773,13 +5003,40 @@
 			"version": "9.0.1",
 			"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
 			"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+			"license": "ISC",
 			"dependencies": {
 				"string-width": "^7.2.0",
 				"strip-ansi": "^7.1.0",
 				"wrap-ansi": "^9.0.0"
 			},
 			"engines": {
-				"node": ">=20"
+				"node": ">=20"
+			}
+		},
+		"node_modules/cliui/node_modules/string-width": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+			"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+			"license": "MIT",
+			"dependencies": {
+				"emoji-regex": "^10.3.0",
+				"get-east-asian-width": "^1.0.0",
+				"strip-ansi": "^7.1.0"
+			},
+			"engines": {
+				"node": ">=18"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/sindresorhus"
+			}
+		},
+		"node_modules/clsx": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+			"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
 			}
 		},
 		"node_modules/color-convert": {
@@ -4787,6 +5044,7 @@
 			"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 			"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"color-name": "~1.1.4"
 			},
@@ -4798,24 +5056,28 @@
 			"version": "1.1.4",
 			"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/concat-map": {
 			"version": "0.0.1",
 			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
 			"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/confbox": {
 			"version": "0.2.4",
 			"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
 			"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/content-disposition": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
-			"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+			"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -4828,6 +5090,7 @@
 			"version": "1.0.5",
 			"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
 			"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -4836,12 +5099,14 @@
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
 			"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/cookie": {
 			"version": "0.7.2",
 			"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
 			"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -4850,15 +5115,20 @@
 			"version": "1.2.2",
 			"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
 			"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.6.0"
 			}
 		},
 		"node_modules/core-js-pure": {
-			"version": "3.48.0",
-			"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz",
-			"integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==",
+			"version": "3.50.0",
+			"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz",
+			"integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==",
 			"hasInstallScript": true,
+			"license": "MIT",
+			"engines": {
+				"node": "*"
+			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/core-js"
@@ -4868,6 +5138,7 @@
 			"version": "2.8.6",
 			"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
 			"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+			"license": "MIT",
 			"dependencies": {
 				"object-assign": "^4",
 				"vary": "^1"
@@ -4880,22 +5151,17 @@
 				"url": "https://opencollective.com/express"
 			}
 		},
-		"node_modules/crelt": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
-			"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
-			"license": "MIT"
-		},
 		"node_modules/critic-markup": {
-			"version": "2.0.0",
-			"resolved": "https://registry.npmjs.org/critic-markup/-/critic-markup-2.0.0.tgz",
-			"integrity": "sha512-382Wtl8XUsFU66xoJ9q1PtLgleIvGbL7AneIftEgciUyw7MF5qdlHPKlvepRwpXald7puNxJpXy+VidHMWu7sg==",
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/critic-markup/-/critic-markup-2.0.1.tgz",
+			"integrity": "sha512-rTlXYcET/FMeRyzibc8ZhabQfx1o84gUTlSbjK+WOthS3uzrtdAJa+mbzpKP64KLCPNzncUIu4483PqaewRG2A==",
 			"license": "MIT"
 		},
 		"node_modules/cron-schedule": {
 			"version": "6.0.0",
 			"resolved": "https://registry.npmjs.org/cron-schedule/-/cron-schedule-6.0.0.tgz",
 			"integrity": "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=20"
 			}
@@ -4904,6 +5170,7 @@
 			"version": "7.0.6",
 			"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 			"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+			"license": "MIT",
 			"dependencies": {
 				"path-key": "^3.1.0",
 				"shebang-command": "^2.0.0",
@@ -4914,14 +5181,14 @@
 			}
 		},
 		"node_modules/css-tree": {
-			"version": "3.1.0",
-			"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
-			"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+			"version": "3.2.1",
+			"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+			"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"mdn-data": "2.12.2",
-				"source-map-js": "^1.0.1"
+				"mdn-data": "2.27.1",
+				"source-map-js": "^1.2.1"
 			},
 			"engines": {
 				"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
@@ -4935,25 +5202,25 @@
 			"license": "MIT"
 		},
 		"node_modules/cssstyle": {
-			"version": "5.3.7",
-			"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
-			"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+			"version": "6.2.0",
+			"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
+			"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@asamuzakjp/css-color": "^4.1.1",
-				"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+				"@asamuzakjp/css-color": "^5.0.1",
+				"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
 				"css-tree": "^3.1.0",
-				"lru-cache": "^11.2.4"
+				"lru-cache": "^11.2.6"
 			},
 			"engines": {
 				"node": ">=20"
 			}
 		},
 		"node_modules/cssstyle/node_modules/lru-cache": {
-			"version": "11.2.5",
-			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
-			"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
+			"version": "11.5.2",
+			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+			"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
 			"dev": true,
 			"license": "BlueOak-1.0.0",
 			"engines": {
@@ -4963,7 +5230,8 @@
 		"node_modules/csstype": {
 			"version": "3.2.3",
 			"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
-			"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+			"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+			"license": "MIT"
 		},
 		"node_modules/data-urls": {
 			"version": "7.0.0",
@@ -4983,6 +5251,7 @@
 			"version": "4.4.3",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
 			"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+			"license": "MIT",
 			"dependencies": {
 				"ms": "^2.1.3"
 			},
@@ -5003,10 +5272,11 @@
 			"license": "MIT"
 		},
 		"node_modules/dedent": {
-			"version": "1.7.1",
-			"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
-			"integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
+			"version": "1.7.2",
+			"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
+			"integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==",
 			"dev": true,
+			"license": "MIT",
 			"peerDependencies": {
 				"babel-plugin-macros": "^3.1.0"
 			},
@@ -5020,12 +5290,14 @@
 			"version": "0.1.4",
 			"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 			"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/depd": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
 			"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
@@ -5045,16 +5317,11 @@
 			"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
 			"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=8"
 			}
 		},
-		"node_modules/detect-node-es": {
-			"version": "1.1.0",
-			"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
-			"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
-			"license": "MIT"
-		},
 		"node_modules/dom-accessibility-api": {
 			"version": "0.5.16",
 			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -5063,19 +5330,11 @@
 			"license": "MIT",
 			"peer": true
 		},
-		"node_modules/dompurify": {
-			"version": "3.3.3",
-			"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
-			"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
-			"license": "(MPL-2.0 OR Apache-2.0)",
-			"optionalDependencies": {
-				"@types/trusted-types": "^2.0.7"
-			}
-		},
 		"node_modules/dunder-proto": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
 			"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+			"license": "MIT",
 			"dependencies": {
 				"call-bind-apply-helpers": "^1.0.1",
 				"es-errors": "^1.3.0",
@@ -5088,47 +5347,52 @@
 		"node_modules/ee-first": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
-			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+			"license": "MIT"
 		},
 		"node_modules/electron-to-chromium": {
-			"version": "1.5.286",
-			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
-			"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
-			"dev": true
+			"version": "1.5.417",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.417.tgz",
+			"integrity": "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==",
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/emoji-regex": {
 			"version": "10.6.0",
 			"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
-			"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="
+			"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+			"license": "MIT"
 		},
 		"node_modules/encodeurl": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
 			"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
 		},
 		"node_modules/enhanced-resolve": {
-			"version": "5.19.0",
-			"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
-			"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+			"version": "5.24.5",
+			"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
+			"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"graceful-fs": "^4.2.4",
-				"tapable": "^2.3.0"
+				"tapable": "^2.3.3"
 			},
 			"engines": {
 				"node": ">=10.13.0"
 			}
 		},
 		"node_modules/entities": {
-			"version": "4.5.0",
-			"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
-			"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+			"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
 			"license": "BSD-2-Clause",
 			"engines": {
-				"node": ">=0.12"
+				"node": ">=20.19.0"
 			},
 			"funding": {
 				"url": "https://github.com/fb55/entities?sponsor=1"
@@ -5139,6 +5403,7 @@
 			"resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
 			"integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
 			"dev": true,
+			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/antfu"
 			}
@@ -5147,6 +5412,7 @@
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
 			"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			}
@@ -5155,6 +5421,7 @@
 			"version": "1.3.0",
 			"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
 			"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			}
@@ -5163,12 +5430,14 @@
 			"version": "1.7.0",
 			"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
 			"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/es-object-atoms": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
-			"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+			"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+			"license": "MIT",
 			"dependencies": {
 				"es-errors": "^1.3.0"
 			},
@@ -5177,11 +5446,12 @@
 			}
 		},
 		"node_modules/esbuild": {
-			"version": "0.27.3",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
-			"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+			"version": "0.28.2",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+			"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
 			"dev": true,
 			"hasInstallScript": true,
+			"license": "MIT",
 			"bin": {
 				"esbuild": "bin/esbuild"
 			},
@@ -5189,38 +5459,39 @@
 				"node": ">=18"
 			},
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.27.3",
-				"@esbuild/android-arm": "0.27.3",
-				"@esbuild/android-arm64": "0.27.3",
-				"@esbuild/android-x64": "0.27.3",
-				"@esbuild/darwin-arm64": "0.27.3",
-				"@esbuild/darwin-x64": "0.27.3",
-				"@esbuild/freebsd-arm64": "0.27.3",
-				"@esbuild/freebsd-x64": "0.27.3",
-				"@esbuild/linux-arm": "0.27.3",
-				"@esbuild/linux-arm64": "0.27.3",
-				"@esbuild/linux-ia32": "0.27.3",
-				"@esbuild/linux-loong64": "0.27.3",
-				"@esbuild/linux-mips64el": "0.27.3",
-				"@esbuild/linux-ppc64": "0.27.3",
-				"@esbuild/linux-riscv64": "0.27.3",
-				"@esbuild/linux-s390x": "0.27.3",
-				"@esbuild/linux-x64": "0.27.3",
-				"@esbuild/netbsd-arm64": "0.27.3",
-				"@esbuild/netbsd-x64": "0.27.3",
-				"@esbuild/openbsd-arm64": "0.27.3",
-				"@esbuild/openbsd-x64": "0.27.3",
-				"@esbuild/openharmony-arm64": "0.27.3",
-				"@esbuild/sunos-x64": "0.27.3",
-				"@esbuild/win32-arm64": "0.27.3",
-				"@esbuild/win32-ia32": "0.27.3",
-				"@esbuild/win32-x64": "0.27.3"
+				"@esbuild/aix-ppc64": "0.28.2",
+				"@esbuild/android-arm": "0.28.2",
+				"@esbuild/android-arm64": "0.28.2",
+				"@esbuild/android-x64": "0.28.2",
+				"@esbuild/darwin-arm64": "0.28.2",
+				"@esbuild/darwin-x64": "0.28.2",
+				"@esbuild/freebsd-arm64": "0.28.2",
+				"@esbuild/freebsd-x64": "0.28.2",
+				"@esbuild/linux-arm": "0.28.2",
+				"@esbuild/linux-arm64": "0.28.2",
+				"@esbuild/linux-ia32": "0.28.2",
+				"@esbuild/linux-loong64": "0.28.2",
+				"@esbuild/linux-mips64el": "0.28.2",
+				"@esbuild/linux-ppc64": "0.28.2",
+				"@esbuild/linux-riscv64": "0.28.2",
+				"@esbuild/linux-s390x": "0.28.2",
+				"@esbuild/linux-x64": "0.28.2",
+				"@esbuild/netbsd-arm64": "0.28.2",
+				"@esbuild/netbsd-x64": "0.28.2",
+				"@esbuild/openbsd-arm64": "0.28.2",
+				"@esbuild/openbsd-x64": "0.28.2",
+				"@esbuild/openharmony-arm64": "0.28.2",
+				"@esbuild/sunos-x64": "0.28.2",
+				"@esbuild/win32-arm64": "0.28.2",
+				"@esbuild/win32-ia32": "0.28.2",
+				"@esbuild/win32-x64": "0.28.2"
 			}
 		},
 		"node_modules/escalade": {
 			"version": "3.2.0",
 			"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
 			"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			}
@@ -5228,12 +5499,15 @@
 		"node_modules/escape-html": {
 			"version": "1.0.3",
 			"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-			"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+			"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+			"license": "MIT"
 		},
 		"node_modules/escape-string-regexp": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
 			"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=10"
 			},
@@ -5242,24 +5516,26 @@
 			}
 		},
 		"node_modules/eslint": {
-			"version": "9.39.2",
-			"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
-			"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+			"version": "9.39.5",
+			"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+			"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+			"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@eslint-community/eslint-utils": "^4.8.0",
 				"@eslint-community/regexpp": "^4.12.1",
-				"@eslint/config-array": "^0.21.1",
+				"@eslint/config-array": "^0.21.2",
 				"@eslint/config-helpers": "^0.4.2",
 				"@eslint/core": "^0.17.0",
-				"@eslint/eslintrc": "^3.3.1",
-				"@eslint/js": "9.39.2",
+				"@eslint/eslintrc": "^3.3.6",
+				"@eslint/js": "9.39.5",
 				"@eslint/plugin-kit": "^0.4.1",
 				"@humanfs/node": "^0.16.6",
 				"@humanwhocodes/module-importer": "^1.0.1",
 				"@humanwhocodes/retry": "^0.4.2",
 				"@types/estree": "^1.0.6",
-				"ajv": "^6.12.4",
+				"ajv": "^6.14.0",
 				"chalk": "^4.0.0",
 				"cross-spawn": "^7.0.6",
 				"debug": "^4.3.2",
@@ -5278,7 +5554,7 @@
 				"is-glob": "^4.0.0",
 				"json-stable-stringify-without-jsonify": "^1.0.1",
 				"lodash.merge": "^4.6.2",
-				"minimatch": "^3.1.2",
+				"minimatch": "^3.1.5",
 				"natural-compare": "^1.4.0",
 				"optionator": "^0.9.3"
 			},
@@ -5301,10 +5577,11 @@
 			}
 		},
 		"node_modules/eslint-plugin-react-hooks": {
-			"version": "7.0.1",
-			"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
-			"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+			"version": "7.1.1",
+			"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+			"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@babel/core": "^7.24.4",
 				"@babel/parser": "^7.24.4",
@@ -5316,7 +5593,7 @@
 				"node": ">=18"
 			},
 			"peerDependencies": {
-				"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+				"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
 			}
 		},
 		"node_modules/eslint-scope": {
@@ -5324,6 +5601,7 @@
 			"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
 			"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"dependencies": {
 				"esrecurse": "^4.3.0",
 				"estraverse": "^5.2.0"
@@ -5340,6 +5618,7 @@
 			"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
 			"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
@@ -5348,10 +5627,11 @@
 			}
 		},
 		"node_modules/eslint/node_modules/ajv": {
-			"version": "6.12.6",
-			"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-			"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+			"version": "6.15.0",
+			"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+			"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"fast-deep-equal": "^3.1.1",
 				"fast-json-stable-stringify": "^2.0.0",
@@ -5367,13 +5647,15 @@
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
 			"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/espree": {
 			"version": "10.4.0",
 			"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
 			"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"dependencies": {
 				"acorn": "^8.15.0",
 				"acorn-jsx": "^5.3.2",
@@ -5391,6 +5673,7 @@
 			"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
 			"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
 			"dev": true,
+			"license": "BSD-3-Clause",
 			"dependencies": {
 				"estraverse": "^5.1.0"
 			},
@@ -5403,6 +5686,7 @@
 			"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
 			"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"dependencies": {
 				"estraverse": "^5.2.0"
 			},
@@ -5415,6 +5699,7 @@
 			"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
 			"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"engines": {
 				"node": ">=4.0"
 			}
@@ -5424,6 +5709,7 @@
 			"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
 			"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@types/estree": "^1.0.0"
 			}
@@ -5433,6 +5719,7 @@
 			"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -5441,6 +5728,7 @@
 			"version": "1.8.1",
 			"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
 			"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -5448,12 +5736,14 @@
 		"node_modules/event-target-polyfill": {
 			"version": "0.0.4",
 			"resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz",
-			"integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="
+			"integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==",
+			"license": "MIT"
 		},
 		"node_modules/eventsource": {
 			"version": "3.0.7",
 			"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
 			"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+			"license": "MIT",
 			"dependencies": {
 				"eventsource-parser": "^3.0.1"
 			},
@@ -5462,9 +5752,10 @@
 			}
 		},
 		"node_modules/eventsource-parser": {
-			"version": "3.0.6",
-			"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
-			"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+			"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18.0.0"
 			}
@@ -5474,6 +5765,7 @@
 			"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
 			"integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			},
@@ -5482,10 +5774,11 @@
 			}
 		},
 		"node_modules/expect-type": {
-			"version": "1.3.0",
-			"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
-			"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+			"version": "1.4.0",
+			"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+			"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
 			"dev": true,
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=12.0.0"
 			}
@@ -5494,6 +5787,7 @@
 			"version": "5.2.1",
 			"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
 			"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+			"license": "MIT",
 			"dependencies": {
 				"accepts": "^2.0.0",
 				"body-parser": "^2.2.1",
@@ -5536,6 +5830,7 @@
 			"version": "7.5.1",
 			"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
 			"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 16"
 			},
@@ -5547,20 +5842,22 @@
 			}
 		},
 		"node_modules/exsolve": {
-			"version": "1.0.8",
-			"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
-			"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
-			"dev": true
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+			"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/fast-deep-equal": {
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-			"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+			"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+			"license": "MIT"
 		},
 		"node_modules/fast-equals": {
-			"version": "5.4.0",
-			"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
-			"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+			"version": "5.4.1",
+			"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
+			"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
 			"license": "MIT",
 			"engines": {
 				"node": ">=6.0.0"
@@ -5570,18 +5867,20 @@
 			"version": "2.1.0",
 			"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
 			"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/fast-levenshtein": {
 			"version": "2.0.6",
 			"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
 			"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/fast-uri": {
-			"version": "3.1.0",
-			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
-			"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+			"version": "3.1.6",
+			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
+			"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
 			"funding": [
 				{
 					"type": "github",
@@ -5591,7 +5890,8 @@
 					"type": "opencollective",
 					"url": "https://opencollective.com/fastify"
 				}
-			]
+			],
+			"license": "BSD-3-Clause"
 		},
 		"node_modules/fathom-client": {
 			"version": "3.7.2",
@@ -5603,6 +5903,7 @@
 			"version": "6.5.0",
 			"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
 			"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=12.0.0"
 			},
@@ -5620,6 +5921,7 @@
 			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
 			"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"flat-cache": "^4.0.0"
 			},
@@ -5631,6 +5933,7 @@
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
 			"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+			"license": "MIT",
 			"dependencies": {
 				"debug": "^4.4.0",
 				"encodeurl": "^2.0.0",
@@ -5652,6 +5955,7 @@
 			"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
 			"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"locate-path": "^6.0.0",
 				"path-exists": "^4.0.0"
@@ -5668,6 +5972,7 @@
 			"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
 			"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"flatted": "^3.2.9",
 				"keyv": "^4.5.4"
@@ -5677,15 +5982,17 @@
 			}
 		},
 		"node_modules/flatted": {
-			"version": "3.3.3",
-			"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
-			"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
-			"dev": true
+			"version": "3.4.4",
+			"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+			"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/forwarded": {
 			"version": "0.2.0",
 			"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
 			"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -5694,6 +6001,7 @@
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
 			"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
@@ -5704,6 +6012,7 @@
 			"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
 			"dev": true,
 			"hasInstallScript": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -5716,6 +6025,7 @@
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
 			"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/ljharb"
 			}
@@ -5725,6 +6035,7 @@
 			"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
 			"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6.9.0"
 			}
@@ -5733,14 +6044,16 @@
 			"version": "2.0.5",
 			"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
 			"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+			"license": "ISC",
 			"engines": {
 				"node": "6.* || 8.* || >= 10.*"
 			}
 		},
 		"node_modules/get-east-asian-width": {
-			"version": "1.4.0",
-			"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
-			"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
+			"version": "1.6.0",
+			"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+			"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -5752,6 +6065,7 @@
 			"version": "1.3.0",
 			"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
 			"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+			"license": "MIT",
 			"dependencies": {
 				"call-bind-apply-helpers": "^1.0.2",
 				"es-define-property": "^1.0.1",
@@ -5771,19 +6085,11 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/get-nonce": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
-			"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
-			"license": "MIT",
-			"engines": {
-				"node": ">=6"
-			}
-		},
 		"node_modules/get-proto": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
 			"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+			"license": "MIT",
 			"dependencies": {
 				"dunder-proto": "^1.0.1",
 				"es-object-atoms": "^1.0.0"
@@ -5797,6 +6103,7 @@
 			"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
 			"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
 			"dev": true,
+			"license": "ISC",
 			"dependencies": {
 				"is-glob": "^4.0.3"
 			},
@@ -5809,6 +6116,7 @@
 			"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
 			"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -5820,12 +6128,14 @@
 			"version": "0.1.2",
 			"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
 			"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/gopd": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
 			"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			},
@@ -5837,13 +6147,15 @@
 			"version": "4.2.11",
 			"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
 			"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
-			"dev": true
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/has-flag": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
 			"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			}
@@ -5852,6 +6164,7 @@
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
 			"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			},
@@ -5860,9 +6173,10 @@
 			}
 		},
 		"node_modules/hasown": {
-			"version": "2.0.2",
-			"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
-			"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+			"version": "2.0.4",
+			"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+			"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+			"license": "MIT",
 			"dependencies": {
 				"function-bind": "^1.1.2"
 			},
@@ -5874,21 +6188,24 @@
 			"version": "0.25.1",
 			"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
 			"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/hermes-parser": {
 			"version": "0.25.1",
 			"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
 			"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"hermes-estree": "0.25.1"
 			}
 		},
 		"node_modules/hono": {
-			"version": "4.11.8",
-			"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
-			"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
+			"version": "4.13.5",
+			"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
+			"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
+			"license": "MIT",
 			"peer": true,
 			"engines": {
 				"node": ">=16.9.0"
@@ -5911,12 +6228,14 @@
 			"version": "2.0.2",
 			"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
 			"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/http-errors": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
 			"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+			"license": "MIT",
 			"dependencies": {
 				"depd": "~2.0.0",
 				"inherits": "~2.0.4",
@@ -5961,9 +6280,10 @@
 			}
 		},
 		"node_modules/iconv-lite": {
-			"version": "0.7.2",
-			"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
-			"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+			"version": "0.7.3",
+			"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+			"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+			"license": "MIT",
 			"dependencies": {
 				"safer-buffer": ">= 2.1.2 < 3.0.0"
 			},
@@ -5980,6 +6300,7 @@
 			"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
 			"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">= 4"
 			}
@@ -5989,6 +6310,7 @@
 			"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 			"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"parent-module": "^1.0.0",
 				"resolve-from": "^4.0.0"
@@ -6005,6 +6327,7 @@
 			"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
 			"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.8.19"
 			}
@@ -6022,12 +6345,14 @@
 		"node_modules/inherits": {
 			"version": "2.0.4",
 			"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-			"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+			"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+			"license": "ISC"
 		},
 		"node_modules/ipaddr.js": {
 			"version": "1.9.1",
 			"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
 			"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.10"
 			}
@@ -6036,6 +6361,7 @@
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
 			"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -6044,6 +6370,7 @@
 			"version": "4.0.3",
 			"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
 			"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+			"license": "MIT",
 			"dependencies": {
 				"is-extglob": "^2.1.1"
 			},
@@ -6061,12 +6388,14 @@
 		"node_modules/is-promise": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
-			"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
+			"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+			"license": "MIT"
 		},
 		"node_modules/isbot": {
-			"version": "5.1.34",
-			"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.34.tgz",
-			"integrity": "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A==",
+			"version": "5.2.2",
+			"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz",
+			"integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==",
+			"license": "Unlicense",
 			"engines": {
 				"node": ">=18"
 			}
@@ -6074,7 +6403,8 @@
 		"node_modules/isexe": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-			"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+			"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+			"license": "ISC"
 		},
 		"node_modules/isomorphic.js": {
 			"version": "0.2.5",
@@ -6091,6 +6421,7 @@
 			"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
 			"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
 			"dev": true,
+			"license": "BSD-3-Clause",
 			"engines": {
 				"node": ">=8"
 			}
@@ -6100,6 +6431,7 @@
 			"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
 			"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
 			"dev": true,
+			"license": "BSD-3-Clause",
 			"dependencies": {
 				"istanbul-lib-coverage": "^3.0.0",
 				"make-dir": "^4.0.0",
@@ -6114,6 +6446,7 @@
 			"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
 			"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
 			"dev": true,
+			"license": "BSD-3-Clause",
 			"dependencies": {
 				"html-escaper": "^2.0.0",
 				"istanbul-lib-report": "^3.0.0"
@@ -6123,37 +6456,52 @@
 			}
 		},
 		"node_modules/jiti": {
-			"version": "2.6.1",
-			"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
-			"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+			"version": "2.7.0",
+			"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+			"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
 			"dev": true,
+			"license": "MIT",
 			"bin": {
 				"jiti": "lib/jiti-cli.mjs"
 			}
 		},
 		"node_modules/jose": {
-			"version": "6.1.3",
-			"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
-			"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+			"version": "6.2.10",
+			"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
+			"integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
+			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/panva"
 			}
 		},
 		"node_modules/js-base64": {
-			"version": "3.7.8",
-			"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
-			"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="
+			"version": "3.9.3",
+			"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
+			"integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==",
+			"license": "BSD-3-Clause"
 		},
 		"node_modules/js-tokens": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
 			"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/js-yaml": {
-			"version": "4.1.1",
-			"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
-			"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+			"version": "4.3.2",
+			"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+			"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/nodeca"
+				}
+			],
+			"license": "MIT",
 			"dependencies": {
 				"argparse": "^2.0.1"
 			},
@@ -6162,16 +6510,17 @@
 			}
 		},
 		"node_modules/jsdom": {
-			"version": "28.0.0",
-			"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
-			"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
+			"version": "28.1.0",
+			"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+			"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
 				"@acemir/cssom": "^0.9.31",
-				"@asamuzakjp/dom-selector": "^6.7.6",
+				"@asamuzakjp/dom-selector": "^6.8.1",
+				"@bramus/specificity": "^2.4.2",
 				"@exodus/bytes": "^1.11.0",
-				"cssstyle": "^5.3.7",
+				"cssstyle": "^6.0.1",
 				"data-urls": "^7.0.0",
 				"decimal.js": "^10.6.0",
 				"html-encoding-sniffer": "^6.0.0",
@@ -6182,7 +6531,7 @@
 				"saxes": "^6.0.0",
 				"symbol-tree": "^3.2.4",
 				"tough-cookie": "^6.0.0",
-				"undici": "^7.20.0",
+				"undici": "^7.21.0",
 				"w3c-xmlserializer": "^5.0.0",
 				"webidl-conversions": "^8.0.1",
 				"whatwg-mimetype": "^5.0.0",
@@ -6202,9 +6551,9 @@
 			}
 		},
 		"node_modules/jsdom/node_modules/undici": {
-			"version": "7.21.0",
-			"resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
-			"integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
+			"version": "7.29.0",
+			"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+			"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
 			"dev": true,
 			"license": "MIT",
 			"engines": {
@@ -6216,6 +6565,7 @@
 			"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
 			"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
 			"dev": true,
+			"license": "MIT",
 			"bin": {
 				"jsesc": "bin/jsesc"
 			},
@@ -6227,17 +6577,20 @@
 			"version": "3.0.1",
 			"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
 			"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/json-schema": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
-			"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
+			"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+			"license": "(AFL-2.1 OR BSD-3-Clause)"
 		},
 		"node_modules/json-schema-to-typescript": {
 			"version": "15.0.4",
 			"resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
 			"integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
+			"license": "MIT",
 			"dependencies": {
 				"@apidevtools/json-schema-ref-parser": "^11.5.5",
 				"@types/json-schema": "^7.0.15",
@@ -6259,24 +6612,28 @@
 		"node_modules/json-schema-traverse": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
-			"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
+			"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+			"license": "MIT"
 		},
 		"node_modules/json-schema-typed": {
 			"version": "8.0.2",
 			"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
-			"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="
+			"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+			"license": "BSD-2-Clause"
 		},
 		"node_modules/json-stable-stringify-without-jsonify": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
 			"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/json5": {
 			"version": "2.2.3",
 			"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
 			"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
 			"dev": true,
+			"license": "MIT",
 			"bin": {
 				"json5": "lib/cli.js"
 			},
@@ -6289,6 +6646,7 @@
 			"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
 			"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"json-buffer": "3.0.1"
 			}
@@ -6298,6 +6656,7 @@
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
 			"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			}
@@ -6307,6 +6666,7 @@
 			"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
 			"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"prelude-ls": "^1.2.1",
 				"type-check": "~0.4.0"
@@ -6337,10 +6697,11 @@
 			}
 		},
 		"node_modules/lightningcss": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
-			"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+			"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
 			"dev": true,
+			"license": "MPL-2.0",
 			"dependencies": {
 				"detect-libc": "^2.0.3"
 			},
@@ -6352,27 +6713,28 @@
 				"url": "https://opencollective.com/parcel"
 			},
 			"optionalDependencies": {
-				"lightningcss-android-arm64": "1.30.2",
-				"lightningcss-darwin-arm64": "1.30.2",
-				"lightningcss-darwin-x64": "1.30.2",
-				"lightningcss-freebsd-x64": "1.30.2",
-				"lightningcss-linux-arm-gnueabihf": "1.30.2",
-				"lightningcss-linux-arm64-gnu": "1.30.2",
-				"lightningcss-linux-arm64-musl": "1.30.2",
-				"lightningcss-linux-x64-gnu": "1.30.2",
-				"lightningcss-linux-x64-musl": "1.30.2",
-				"lightningcss-win32-arm64-msvc": "1.30.2",
-				"lightningcss-win32-x64-msvc": "1.30.2"
+				"lightningcss-android-arm64": "1.32.0",
+				"lightningcss-darwin-arm64": "1.32.0",
+				"lightningcss-darwin-x64": "1.32.0",
+				"lightningcss-freebsd-x64": "1.32.0",
+				"lightningcss-linux-arm-gnueabihf": "1.32.0",
+				"lightningcss-linux-arm64-gnu": "1.32.0",
+				"lightningcss-linux-arm64-musl": "1.32.0",
+				"lightningcss-linux-x64-gnu": "1.32.0",
+				"lightningcss-linux-x64-musl": "1.32.0",
+				"lightningcss-win32-arm64-msvc": "1.32.0",
+				"lightningcss-win32-x64-msvc": "1.32.0"
 			}
 		},
 		"node_modules/lightningcss-android-arm64": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
-			"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+			"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"android"
@@ -6386,13 +6748,14 @@
 			}
 		},
 		"node_modules/lightningcss-darwin-arm64": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
-			"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+			"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -6406,13 +6769,14 @@
 			}
 		},
 		"node_modules/lightningcss-darwin-x64": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
-			"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+			"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -6426,13 +6790,14 @@
 			}
 		},
 		"node_modules/lightningcss-freebsd-x64": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
-			"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+			"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"freebsd"
@@ -6446,13 +6811,14 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm-gnueabihf": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
-			"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+			"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -6466,13 +6832,17 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm64-gnu": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
-			"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+			"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -6486,13 +6856,17 @@
 			}
 		},
 		"node_modules/lightningcss-linux-arm64-musl": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
-			"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+			"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -6506,13 +6880,17 @@
 			}
 		},
 		"node_modules/lightningcss-linux-x64-gnu": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
-			"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+			"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"glibc"
+			],
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -6526,13 +6904,17 @@
 			}
 		},
 		"node_modules/lightningcss-linux-x64-musl": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
-			"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+			"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"libc": [
+				"musl"
+			],
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"linux"
@@ -6546,13 +6928,14 @@
 			}
 		},
 		"node_modules/lightningcss-win32-arm64-msvc": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
-			"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+			"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"win32"
@@ -6566,13 +6949,14 @@
 			}
 		},
 		"node_modules/lightningcss-win32-x64-msvc": {
-			"version": "1.30.2",
-			"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
-			"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+			"version": "1.32.0",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+			"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MPL-2.0",
 			"optional": true,
 			"os": [
 				"win32"
@@ -6586,19 +6970,36 @@
 			}
 		},
 		"node_modules/linkify-it": {
-			"version": "5.0.0",
-			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
-			"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
+			"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/markdown-it"
+				}
+			],
 			"license": "MIT",
 			"dependencies": {
-				"uc.micro": "^2.0.0"
+				"uc.micro": "^3.0.0"
 			}
 		},
+		"node_modules/linkifyjs": {
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
+			"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+			"license": "MIT"
+		},
 		"node_modules/locate-path": {
 			"version": "6.0.0",
 			"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
 			"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"p-locate": "^5.0.0"
 			},
@@ -6610,21 +7011,24 @@
 			}
 		},
 		"node_modules/lodash": {
-			"version": "4.17.23",
-			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
-			"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
+			"version": "4.18.1",
+			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+			"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+			"license": "MIT"
 		},
 		"node_modules/lodash.merge": {
 			"version": "4.6.2",
 			"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
 			"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/lru-cache": {
 			"version": "5.1.1",
 			"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
 			"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
 			"dev": true,
+			"license": "ISC",
 			"dependencies": {
 				"yallist": "^3.0.2"
 			}
@@ -6645,18 +7049,20 @@
 			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
 			"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@jridgewell/sourcemap-codec": "^1.5.5"
 			}
 		},
 		"node_modules/magicast": {
-			"version": "0.5.2",
-			"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
-			"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+			"version": "0.5.4",
+			"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+			"integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@babel/parser": "^7.29.0",
-				"@babel/types": "^7.29.0",
+				"@babel/parser": "^7.29.7",
+				"@babel/types": "^7.29.7",
 				"source-map-js": "^1.2.1"
 			}
 		},
@@ -6665,6 +7071,7 @@
 			"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
 			"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"semver": "^7.5.3"
 			},
@@ -6676,67 +7083,88 @@
 			}
 		},
 		"node_modules/markdown-it": {
-			"version": "14.1.0",
-			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
-			"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+			"version": "15.0.1",
+			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz",
+			"integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/markdown-it"
+				}
+			],
 			"license": "MIT",
 			"dependencies": {
-				"argparse": "^2.0.1",
-				"entities": "^4.4.0",
-				"linkify-it": "^5.0.0",
-				"mdurl": "^2.0.0",
+				"argparse": "^3.0.0",
+				"entities": "^8.0.0",
+				"linkify-it": "^6.0.0",
+				"mdurl": "^2.1.0",
 				"punycode.js": "^2.3.1",
-				"uc.micro": "^2.1.0"
+				"uc.micro": "^3.0.0"
 			},
 			"bin": {
 				"markdown-it": "bin/markdown-it.mjs"
 			}
 		},
-		"node_modules/marked": {
-			"version": "17.0.1",
-			"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
-			"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
-			"license": "MIT",
-			"bin": {
-				"marked": "bin/marked.js"
-			},
-			"engines": {
-				"node": ">= 20"
-			}
+		"node_modules/markdown-it/node_modules/argparse": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz",
+			"integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/nodeca"
+				}
+			],
+			"license": "PSF-2.0"
 		},
 		"node_modules/math-intrinsics": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
 			"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			}
 		},
 		"node_modules/mdn-data": {
-			"version": "2.12.2",
-			"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
-			"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+			"version": "2.27.1",
+			"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+			"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
 			"dev": true,
 			"license": "CC0-1.0"
 		},
 		"node_modules/mdurl": {
-			"version": "2.0.0",
-			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
-			"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
+			"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
 			"license": "MIT"
 		},
 		"node_modules/media-typer": {
-			"version": "1.1.0",
-			"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
-			"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+			"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
 			}
 		},
 		"node_modules/merge-descriptors": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
 			"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -6748,6 +7176,7 @@
 			"version": "1.54.0",
 			"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
 			"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -6756,6 +7185,7 @@
 			"version": "3.0.2",
 			"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
 			"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+			"license": "MIT",
 			"dependencies": {
 				"mime-db": "^1.54.0"
 			},
@@ -6771,6 +7201,7 @@
 			"version": "3.0.28",
 			"resolved": "https://registry.npmjs.org/mimetext/-/mimetext-3.0.28.tgz",
 			"integrity": "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==",
+			"license": "MIT",
 			"dependencies": {
 				"@babel/runtime": "^7.26.0",
 				"@babel/runtime-corejs3": "^7.26.0",
@@ -6786,6 +7217,7 @@
 			"version": "1.52.0",
 			"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
 			"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
 			}
@@ -6794,6 +7226,7 @@
 			"version": "2.1.35",
 			"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
 			"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+			"license": "MIT",
 			"dependencies": {
 				"mime-db": "1.52.0"
 			},
@@ -6812,30 +7245,39 @@
 			}
 		},
 		"node_modules/miniflare": {
-			"version": "4.20260205.0",
-			"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz",
-			"integrity": "sha512-jG1TknEDeFqcq/z5gsOm1rKeg4cNG7ruWxEuiPxl3pnQumavxo8kFpeQC6XKVpAhh2PI9ODGyIYlgd77sTHl5g==",
+			"version": "5.20260828.0-alpha",
+			"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260828.0-alpha.tgz",
+			"integrity": "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@cspotcode/source-map-support": "0.8.1",
-				"sharp": "^0.34.5",
-				"undici": "7.18.2",
-				"workerd": "1.20260205.0",
-				"ws": "8.18.0",
+				"sharp": "0.35.2",
+				"undici": "7.29.0",
+				"workerd": "1.20260828.1",
+				"ws": "8.21.0",
 				"youch": "4.1.0-beta.10"
 			},
-			"bin": {
-				"miniflare": "bootstrap.js"
-			},
 			"engines": {
-				"node": ">=18.0.0"
+				"node": ">=22.0.0"
+			}
+		},
+		"node_modules/miniflare/node_modules/undici": {
+			"version": "7.29.0",
+			"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+			"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=20.18.1"
 			}
 		},
 		"node_modules/minimatch": {
-			"version": "3.1.2",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-			"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+			"version": "3.1.5",
+			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+			"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
 			"dev": true,
+			"license": "ISC",
 			"dependencies": {
 				"brace-expansion": "^1.1.7"
 			},
@@ -6847,6 +7289,7 @@
 			"version": "1.2.8",
 			"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
 			"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/ljharb"
 			}
@@ -6854,18 +7297,20 @@
 		"node_modules/ms": {
 			"version": "2.1.3",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+			"license": "MIT"
 		},
 		"node_modules/nanoid": {
-			"version": "5.1.6",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
-			"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
+			"version": "5.1.16",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
+			"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
 			"funding": [
 				{
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.js"
 			},
@@ -6877,26 +7322,53 @@
 			"version": "1.4.0",
 			"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
 			"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/negotiator": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
-			"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+			"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+			"license": "MIT",
+			"dependencies": {
+				"content-type": "^2.1.0"
+			},
 			"engines": {
-				"node": ">= 0.6"
+				"node": ">=18"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
+			}
+		},
+		"node_modules/negotiator/node_modules/content-type": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+			"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=18"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
 			}
 		},
 		"node_modules/node-releases": {
-			"version": "2.0.27",
-			"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
-			"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
-			"dev": true
+			"version": "2.0.54",
+			"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+			"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=18"
+			}
 		},
 		"node_modules/object-assign": {
 			"version": "4.1.1",
 			"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
 			"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -6905,6 +7377,7 @@
 			"version": "1.13.4",
 			"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
 			"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.4"
 			},
@@ -6913,19 +7386,24 @@
 			}
 		},
 		"node_modules/obug": {
-			"version": "2.1.1",
-			"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
-			"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+			"version": "2.1.4",
+			"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+			"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
 			"dev": true,
 			"funding": [
 				"https://github.com/sponsors/sxzz",
 				"https://opencollective.com/debug"
-			]
+			],
+			"license": "MIT",
+			"engines": {
+				"node": ">=12.20.0"
+			}
 		},
 		"node_modules/on-finished": {
 			"version": "2.4.1",
 			"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
 			"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+			"license": "MIT",
 			"dependencies": {
 				"ee-first": "1.1.1"
 			},
@@ -6937,6 +7415,7 @@
 			"version": "1.4.0",
 			"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
 			"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+			"license": "ISC",
 			"dependencies": {
 				"wrappy": "1"
 			}
@@ -6946,6 +7425,7 @@
 			"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
 			"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"deep-is": "^0.1.3",
 				"fast-levenshtein": "^2.0.6",
@@ -6969,6 +7449,7 @@
 			"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
 			"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"yocto-queue": "^0.1.0"
 			},
@@ -6984,6 +7465,7 @@
 			"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
 			"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"p-limit": "^3.0.2"
 			},
@@ -6995,10 +7477,11 @@
 			}
 		},
 		"node_modules/p-map": {
-			"version": "7.0.4",
-			"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
-			"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
+			"version": "7.0.7",
+			"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz",
+			"integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -7011,6 +7494,7 @@
 			"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
 			"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"callsites": "^3.0.0"
 			},
@@ -7019,54 +7503,32 @@
 			}
 		},
 		"node_modules/parse5": {
-			"version": "8.0.0",
-			"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
-			"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+			"version": "8.0.1",
+			"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+			"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"entities": "^6.0.0"
+				"entities": "^8.0.0"
 			},
 			"funding": {
 				"url": "https://github.com/inikulin/parse5?sponsor=1"
 			}
 		},
-		"node_modules/parse5/node_modules/entities": {
-			"version": "6.0.1",
-			"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
-			"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
-			"dev": true,
-			"license": "BSD-2-Clause",
-			"engines": {
-				"node": ">=0.12"
-			},
-			"funding": {
-				"url": "https://github.com/fb55/entities?sponsor=1"
-			}
-		},
 		"node_modules/parseurl": {
 			"version": "1.3.3",
 			"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
 			"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
 		},
-		"node_modules/partyserver": {
-			"version": "0.1.2",
-			"resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.1.2.tgz",
-			"integrity": "sha512-9ca0wnl8JPYUzPZst76dNndnLxHPnqUHnpYeVa7/+k3rzQeFuPkI9InqOanupkVmEvRTW2/HIKu/wfAeAqTorw==",
-			"dependencies": {
-				"nanoid": "^5.1.6"
-			},
-			"peerDependencies": {
-				"@cloudflare/workers-types": "^4.20240729.0"
-			}
-		},
 		"node_modules/partysocket": {
 			"version": "1.1.11",
 			"resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.11.tgz",
 			"integrity": "sha512-P0EtOQiAwvLriqLgdThcSaREfz3bP77LkLSdmXq680BosPKvGSoGTh/d0g3S+UNmaqcw89Ad7JXHHKyRx3xU9Q==",
+			"license": "MIT",
 			"dependencies": {
 				"event-target-polyfill": "^0.0.4"
 			}
@@ -7076,6 +7538,7 @@
 			"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
 			"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			}
@@ -7084,14 +7547,16 @@
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
 			"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			}
 		},
 		"node_modules/path-to-regexp": {
-			"version": "8.3.0",
-			"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
-			"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+			"version": "8.4.2",
+			"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+			"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+			"license": "MIT",
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/express"
@@ -7101,18 +7566,21 @@
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
 			"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/picocolors": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
 			"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
-			"dev": true
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/picomatch": {
-			"version": "4.0.3",
-			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
-			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+			"version": "4.0.7",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+			"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=12"
 			},
@@ -7124,18 +7592,20 @@
 			"version": "5.0.1",
 			"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
 			"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=16.20.0"
 			}
 		},
 		"node_modules/pkg-types": {
-			"version": "2.3.0",
-			"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
-			"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+			"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"confbox": "^0.2.2",
-				"exsolve": "^1.0.7",
+				"confbox": "^0.2.4",
+				"exsolve": "^1.0.8",
 				"pathe": "^2.0.3"
 			}
 		},
@@ -7143,12 +7613,13 @@
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/postcss": {
-			"version": "8.5.6",
-			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
-			"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+			"version": "8.5.26",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+			"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
 			"dev": true,
 			"funding": [
 				{
@@ -7164,8 +7635,9 @@
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"dependencies": {
-				"nanoid": "^3.3.11",
+				"nanoid": "^3.3.17",
 				"picocolors": "^1.1.1",
 				"source-map-js": "^1.2.1"
 			},
@@ -7174,9 +7646,9 @@
 			}
 		},
 		"node_modules/postcss/node_modules/nanoid": {
-			"version": "3.3.11",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
-			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+			"version": "3.3.18",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+			"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
 			"dev": true,
 			"funding": [
 				{
@@ -7184,6 +7656,7 @@
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.cjs"
 			},
@@ -7196,14 +7669,16 @@
 			"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
 			"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8.0"
 			}
 		},
 		"node_modules/prettier": {
-			"version": "3.8.1",
-			"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
-			"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+			"version": "3.9.6",
+			"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+			"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+			"license": "MIT",
 			"bin": {
 				"prettier": "bin/prettier.cjs"
 			},
@@ -7230,17 +7705,6 @@
 				"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
 			}
 		},
-		"node_modules/pretty-format/node_modules/ansi-regex": {
-			"version": "5.0.1",
-			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-			"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-			"dev": true,
-			"license": "MIT",
-			"peer": true,
-			"engines": {
-				"node": ">=8"
-			}
-		},
 		"node_modules/pretty-format/node_modules/ansi-styles": {
 			"version": "5.2.0",
 			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@@ -7256,27 +7720,18 @@
 			}
 		},
 		"node_modules/prosemirror-changeset": {
-			"version": "2.3.1",
-			"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
-			"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+			"version": "2.4.2",
+			"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
+			"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-transform": "^1.0.0"
 			}
 		},
-		"node_modules/prosemirror-collab": {
-			"version": "1.3.1",
-			"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
-			"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
-			"license": "MIT",
-			"dependencies": {
-				"prosemirror-state": "^1.0.0"
-			}
-		},
 		"node_modules/prosemirror-commands": {
-			"version": "1.7.1",
-			"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
-			"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+			"version": "1.7.2",
+			"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
+			"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-model": "^1.0.0",
@@ -7285,9 +7740,9 @@
 			}
 		},
 		"node_modules/prosemirror-dropcursor": {
-			"version": "1.8.2",
-			"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
-			"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+			"version": "1.8.3",
+			"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
+			"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-state": "^1.0.0",
@@ -7296,9 +7751,9 @@
 			}
 		},
 		"node_modules/prosemirror-gapcursor": {
-			"version": "1.4.0",
-			"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
-			"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+			"version": "1.4.1",
+			"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+			"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-keymap": "^1.0.0",
@@ -7340,9 +7795,9 @@
 			}
 		},
 		"node_modules/prosemirror-markdown": {
-			"version": "1.13.4",
-			"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
-			"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
+			"version": "1.13.6",
+			"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.6.tgz",
+			"integrity": "sha512-dY6g2BXRjkHW2ldNRDKfTF0x0R4ifk5rgPaABd/UhvrymtsGVxTbHn01goEsHAvO9nN0O34cttlW1qg/XUcaIg==",
 			"license": "MIT",
 			"dependencies": {
 				"@types/markdown-it": "^14.0.0",
@@ -7350,34 +7805,77 @@
 				"prosemirror-model": "^1.25.0"
 			}
 		},
-		"node_modules/prosemirror-menu": {
-			"version": "1.2.5",
-			"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
-			"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+		"node_modules/prosemirror-markdown/node_modules/entities": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+			"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+			"license": "BSD-2-Clause",
+			"engines": {
+				"node": ">=0.12"
+			},
+			"funding": {
+				"url": "https://github.com/fb55/entities?sponsor=1"
+			}
+		},
+		"node_modules/prosemirror-markdown/node_modules/linkify-it": {
+			"version": "5.0.2",
+			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+			"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/markdown-it"
+				}
+			],
 			"license": "MIT",
 			"dependencies": {
-				"crelt": "^1.0.0",
-				"prosemirror-commands": "^1.0.0",
-				"prosemirror-history": "^1.0.0",
-				"prosemirror-state": "^1.0.0"
+				"uc.micro": "^2.0.0"
 			}
 		},
-		"node_modules/prosemirror-model": {
-			"version": "1.25.4",
-			"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
-			"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+		"node_modules/prosemirror-markdown/node_modules/markdown-it": {
+			"version": "14.3.1",
+			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.1.tgz",
+			"integrity": "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/puzrin"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/markdown-it"
+				}
+			],
 			"license": "MIT",
 			"dependencies": {
-				"orderedmap": "^2.0.0"
+				"argparse": "^2.0.1",
+				"entities": "^4.5.0",
+				"linkify-it": "^5.0.2",
+				"mdurl": "^2.0.0",
+				"punycode.js": "^2.3.1",
+				"uc.micro": "^2.1.0"
+			},
+			"bin": {
+				"markdown-it": "bin/markdown-it.mjs"
 			}
 		},
-		"node_modules/prosemirror-schema-basic": {
-			"version": "1.2.4",
-			"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
-			"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+		"node_modules/prosemirror-markdown/node_modules/uc.micro": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+			"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+			"license": "MIT"
+		},
+		"node_modules/prosemirror-model": {
+			"version": "1.25.11",
+			"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
+			"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
 			"license": "MIT",
 			"dependencies": {
-				"prosemirror-model": "^1.25.0"
+				"orderedmap": "^2.0.0"
 			}
 		},
 		"node_modules/prosemirror-schema-list": {
@@ -7415,37 +7913,22 @@
 				"prosemirror-view": "^1.41.4"
 			}
 		},
-		"node_modules/prosemirror-trailing-node": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
-			"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@remirror/core-constants": "3.0.0",
-				"escape-string-regexp": "^4.0.0"
-			},
-			"peerDependencies": {
-				"prosemirror-model": "^1.22.1",
-				"prosemirror-state": "^1.4.2",
-				"prosemirror-view": "^1.33.8"
-			}
-		},
 		"node_modules/prosemirror-transform": {
-			"version": "1.11.0",
-			"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
-			"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
+			"version": "1.12.0",
+			"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+			"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-model": "^1.21.0"
 			}
 		},
 		"node_modules/prosemirror-view": {
-			"version": "1.41.6",
-			"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
-			"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
+			"version": "1.42.3",
+			"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
+			"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
 			"license": "MIT",
 			"dependencies": {
-				"prosemirror-model": "^1.20.0",
+				"prosemirror-model": "^1.25.8",
 				"prosemirror-state": "^1.0.0",
 				"prosemirror-transform": "^1.1.0"
 			}
@@ -7454,6 +7937,7 @@
 			"version": "2.0.7",
 			"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
 			"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+			"license": "MIT",
 			"dependencies": {
 				"forwarded": "0.2.0",
 				"ipaddr.js": "1.9.1"
@@ -7467,6 +7951,7 @@
 			"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
 			"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			}
@@ -7481,11 +7966,13 @@
 			}
 		},
 		"node_modules/qs": {
-			"version": "6.14.1",
-			"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
-			"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+			"version": "6.16.0",
+			"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+			"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+			"license": "BSD-3-Clause",
 			"dependencies": {
-				"side-channel": "^1.1.0"
+				"es-define-property": "^1.0.1",
+				"side-channel": "^1.1.1"
 			},
 			"engines": {
 				"node": ">=0.6"
@@ -7495,17 +7982,23 @@
 			}
 		},
 		"node_modules/range-parser": {
-			"version": "1.2.1",
-			"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
-			"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+			"version": "1.3.0",
+			"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+			"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.6"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
 			}
 		},
 		"node_modules/raw-body": {
 			"version": "3.0.2",
 			"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
 			"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+			"license": "MIT",
 			"dependencies": {
 				"bytes": "~3.1.2",
 				"http-errors": "~2.0.1",
@@ -7517,22 +8010,24 @@
 			}
 		},
 		"node_modules/react": {
-			"version": "19.2.4",
-			"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
-			"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+			"version": "19.2.8",
+			"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+			"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.10.0"
 			}
 		},
 		"node_modules/react-dom": {
-			"version": "19.2.4",
-			"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
-			"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+			"version": "19.2.8",
+			"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+			"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+			"license": "MIT",
 			"dependencies": {
 				"scheduler": "^0.27.0"
 			},
 			"peerDependencies": {
-				"react": "^19.2.4"
+				"react": "^19.2.8"
 			}
 		},
 		"node_modules/react-is": {
@@ -7548,61 +8043,16 @@
 			"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
 			"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
 			"dev": true,
-			"engines": {
-				"node": ">=0.10.0"
-			}
-		},
-		"node_modules/react-remove-scroll": {
-			"version": "2.7.2",
-			"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
-			"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
 			"license": "MIT",
-			"dependencies": {
-				"react-remove-scroll-bar": "^2.3.7",
-				"react-style-singleton": "^2.2.3",
-				"tslib": "^2.1.0",
-				"use-callback-ref": "^1.3.3",
-				"use-sidecar": "^1.1.3"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/react-remove-scroll-bar": {
-			"version": "2.3.8",
-			"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
-			"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
-			"license": "MIT",
-			"dependencies": {
-				"react-style-singleton": "^2.2.2",
-				"tslib": "^2.0.0"
-			},
 			"engines": {
-				"node": ">=10"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
+				"node": ">=0.10.0"
 			}
 		},
 		"node_modules/react-router": {
-			"version": "7.13.0",
-			"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
-			"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
+			"version": "7.18.3",
+			"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
+			"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
+			"license": "MIT",
 			"dependencies": {
 				"cookie": "^1.0.1",
 				"set-cookie-parser": "^2.6.0"
@@ -7624,6 +8074,7 @@
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
 			"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -7632,33 +8083,12 @@
 				"url": "https://opencollective.com/express"
 			}
 		},
-		"node_modules/react-style-singleton": {
-			"version": "2.2.3",
-			"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
-			"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
-			"license": "MIT",
-			"dependencies": {
-				"get-nonce": "^1.0.0",
-				"tslib": "^2.0.0"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/readdirp": {
 			"version": "4.1.2",
 			"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
 			"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">= 14.18.0"
 			},
@@ -7685,26 +8115,35 @@
 			"version": "2.0.2",
 			"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
 			"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/reselect": {
+			"version": "5.3.0",
+			"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
+			"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
+			"license": "MIT"
+		},
 		"node_modules/resolve-from": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
 			"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=4"
 			}
 		},
 		"node_modules/rollup": {
-			"version": "4.57.1",
-			"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
-			"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+			"version": "4.63.1",
+			"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+			"integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@types/estree": "1.0.8"
+				"@types/estree": "1.0.9"
 			},
 			"bin": {
 				"rollup": "dist/bin/rollup"
@@ -7714,31 +8153,32 @@
 				"npm": ">=8.0.0"
 			},
 			"optionalDependencies": {
-				"@rollup/rollup-android-arm-eabi": "4.57.1",
-				"@rollup/rollup-android-arm64": "4.57.1",
-				"@rollup/rollup-darwin-arm64": "4.57.1",
-				"@rollup/rollup-darwin-x64": "4.57.1",
-				"@rollup/rollup-freebsd-arm64": "4.57.1",
-				"@rollup/rollup-freebsd-x64": "4.57.1",
-				"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
-				"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
-				"@rollup/rollup-linux-arm64-gnu": "4.57.1",
-				"@rollup/rollup-linux-arm64-musl": "4.57.1",
-				"@rollup/rollup-linux-loong64-gnu": "4.57.1",
-				"@rollup/rollup-linux-loong64-musl": "4.57.1",
-				"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
-				"@rollup/rollup-linux-ppc64-musl": "4.57.1",
-				"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
-				"@rollup/rollup-linux-riscv64-musl": "4.57.1",
-				"@rollup/rollup-linux-s390x-gnu": "4.57.1",
-				"@rollup/rollup-linux-x64-gnu": "4.57.1",
-				"@rollup/rollup-linux-x64-musl": "4.57.1",
-				"@rollup/rollup-openbsd-x64": "4.57.1",
-				"@rollup/rollup-openharmony-arm64": "4.57.1",
-				"@rollup/rollup-win32-arm64-msvc": "4.57.1",
-				"@rollup/rollup-win32-ia32-msvc": "4.57.1",
-				"@rollup/rollup-win32-x64-gnu": "4.57.1",
-				"@rollup/rollup-win32-x64-msvc": "4.57.1",
+				"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+				"@rollup/rollup-android-arm-eabi": "4.63.1",
+				"@rollup/rollup-android-arm64": "4.63.1",
+				"@rollup/rollup-darwin-arm64": "4.63.1",
+				"@rollup/rollup-darwin-x64": "4.63.1",
+				"@rollup/rollup-freebsd-arm64": "4.63.1",
+				"@rollup/rollup-freebsd-x64": "4.63.1",
+				"@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+				"@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+				"@rollup/rollup-linux-arm64-gnu": "4.63.1",
+				"@rollup/rollup-linux-arm64-musl": "4.63.1",
+				"@rollup/rollup-linux-loong64-gnu": "4.63.1",
+				"@rollup/rollup-linux-loong64-musl": "4.63.1",
+				"@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+				"@rollup/rollup-linux-ppc64-musl": "4.63.1",
+				"@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+				"@rollup/rollup-linux-riscv64-musl": "4.63.1",
+				"@rollup/rollup-linux-s390x-gnu": "4.63.1",
+				"@rollup/rollup-linux-x64-gnu": "4.63.1",
+				"@rollup/rollup-linux-x64-musl": "4.63.1",
+				"@rollup/rollup-openbsd-x64": "4.63.1",
+				"@rollup/rollup-openharmony-arm64": "4.63.1",
+				"@rollup/rollup-win32-arm64-msvc": "4.63.1",
+				"@rollup/rollup-win32-ia32-msvc": "4.63.1",
+				"@rollup/rollup-win32-x64-gnu": "4.63.1",
+				"@rollup/rollup-win32-x64-msvc": "4.63.1",
 				"fsevents": "~2.3.2"
 			}
 		},
@@ -7752,6 +8192,7 @@
 			"version": "2.2.0",
 			"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
 			"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+			"license": "MIT",
 			"dependencies": {
 				"debug": "^4.4.0",
 				"depd": "^2.0.0",
@@ -7766,7 +8207,8 @@
 		"node_modules/safer-buffer": {
 			"version": "2.1.2",
 			"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+			"license": "MIT"
 		},
 		"node_modules/saxes": {
 			"version": "6.0.0",
@@ -7784,13 +8226,15 @@
 		"node_modules/scheduler": {
 			"version": "0.27.0",
 			"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
-			"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
+			"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+			"license": "MIT"
 		},
 		"node_modules/semver": {
-			"version": "7.7.4",
-			"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
-			"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+			"version": "7.8.5",
+			"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+			"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
 			"dev": true,
+			"license": "ISC",
 			"bin": {
 				"semver": "bin/semver.js"
 			},
@@ -7802,6 +8246,7 @@
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
 			"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+			"license": "MIT",
 			"dependencies": {
 				"debug": "^4.4.3",
 				"encodeurl": "^2.0.0",
@@ -7827,6 +8272,7 @@
 			"version": "2.2.1",
 			"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
 			"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+			"license": "MIT",
 			"dependencies": {
 				"encodeurl": "^2.0.0",
 				"escape-html": "^1.0.3",
@@ -7844,61 +8290,65 @@
 		"node_modules/set-cookie-parser": {
 			"version": "2.7.2",
 			"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
-			"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
+			"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+			"license": "MIT"
 		},
 		"node_modules/setprototypeof": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
-			"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+			"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+			"license": "ISC"
 		},
 		"node_modules/sharp": {
-			"version": "0.34.5",
-			"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
-			"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+			"version": "0.35.2",
+			"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
+			"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
 			"dev": true,
-			"hasInstallScript": true,
+			"license": "Apache-2.0",
 			"dependencies": {
-				"@img/colour": "^1.0.0",
+				"@img/colour": "^1.1.0",
 				"detect-libc": "^2.1.2",
-				"semver": "^7.7.3"
+				"semver": "^7.8.4"
 			},
 			"engines": {
-				"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+				"node": ">=20.9.0"
 			},
 			"funding": {
 				"url": "https://opencollective.com/libvips"
 			},
 			"optionalDependencies": {
-				"@img/sharp-darwin-arm64": "0.34.5",
-				"@img/sharp-darwin-x64": "0.34.5",
-				"@img/sharp-libvips-darwin-arm64": "1.2.4",
-				"@img/sharp-libvips-darwin-x64": "1.2.4",
-				"@img/sharp-libvips-linux-arm": "1.2.4",
-				"@img/sharp-libvips-linux-arm64": "1.2.4",
-				"@img/sharp-libvips-linux-ppc64": "1.2.4",
-				"@img/sharp-libvips-linux-riscv64": "1.2.4",
-				"@img/sharp-libvips-linux-s390x": "1.2.4",
-				"@img/sharp-libvips-linux-x64": "1.2.4",
-				"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
-				"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
-				"@img/sharp-linux-arm": "0.34.5",
-				"@img/sharp-linux-arm64": "0.34.5",
-				"@img/sharp-linux-ppc64": "0.34.5",
-				"@img/sharp-linux-riscv64": "0.34.5",
-				"@img/sharp-linux-s390x": "0.34.5",
-				"@img/sharp-linux-x64": "0.34.5",
-				"@img/sharp-linuxmusl-arm64": "0.34.5",
-				"@img/sharp-linuxmusl-x64": "0.34.5",
-				"@img/sharp-wasm32": "0.34.5",
-				"@img/sharp-win32-arm64": "0.34.5",
-				"@img/sharp-win32-ia32": "0.34.5",
-				"@img/sharp-win32-x64": "0.34.5"
+				"@img/sharp-darwin-arm64": "0.35.2",
+				"@img/sharp-darwin-x64": "0.35.2",
+				"@img/sharp-freebsd-wasm32": "0.35.2",
+				"@img/sharp-libvips-darwin-arm64": "1.3.1",
+				"@img/sharp-libvips-darwin-x64": "1.3.1",
+				"@img/sharp-libvips-linux-arm": "1.3.1",
+				"@img/sharp-libvips-linux-arm64": "1.3.1",
+				"@img/sharp-libvips-linux-ppc64": "1.3.1",
+				"@img/sharp-libvips-linux-riscv64": "1.3.1",
+				"@img/sharp-libvips-linux-s390x": "1.3.1",
+				"@img/sharp-libvips-linux-x64": "1.3.1",
+				"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
+				"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
+				"@img/sharp-linux-arm": "0.35.2",
+				"@img/sharp-linux-arm64": "0.35.2",
+				"@img/sharp-linux-ppc64": "0.35.2",
+				"@img/sharp-linux-riscv64": "0.35.2",
+				"@img/sharp-linux-s390x": "0.35.2",
+				"@img/sharp-linux-x64": "0.35.2",
+				"@img/sharp-linuxmusl-arm64": "0.35.2",
+				"@img/sharp-linuxmusl-x64": "0.35.2",
+				"@img/sharp-webcontainers-wasm32": "0.35.2",
+				"@img/sharp-win32-arm64": "0.35.2",
+				"@img/sharp-win32-ia32": "0.35.2",
+				"@img/sharp-win32-x64": "0.35.2"
 			}
 		},
 		"node_modules/shebang-command": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
 			"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+			"license": "MIT",
 			"dependencies": {
 				"shebang-regex": "^3.0.0"
 			},
@@ -7910,18 +8360,20 @@
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
 			"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			}
 		},
 		"node_modules/side-channel": {
-			"version": "1.1.0",
-			"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
-			"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+			"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+			"license": "MIT",
 			"dependencies": {
 				"es-errors": "^1.3.0",
-				"object-inspect": "^1.13.3",
-				"side-channel-list": "^1.0.0",
+				"object-inspect": "^1.13.4",
+				"side-channel-list": "^1.0.1",
 				"side-channel-map": "^1.0.1",
 				"side-channel-weakmap": "^1.0.2"
 			},
@@ -7933,12 +8385,13 @@
 			}
 		},
 		"node_modules/side-channel-list": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
-			"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+			"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+			"license": "MIT",
 			"dependencies": {
 				"es-errors": "^1.3.0",
-				"object-inspect": "^1.13.3"
+				"object-inspect": "^1.13.4"
 			},
 			"engines": {
 				"node": ">= 0.4"
@@ -7951,6 +8404,7 @@
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
 			"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+			"license": "MIT",
 			"dependencies": {
 				"call-bound": "^1.0.2",
 				"es-errors": "^1.3.0",
@@ -7968,6 +8422,7 @@
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
 			"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+			"license": "MIT",
 			"dependencies": {
 				"call-bound": "^1.0.2",
 				"es-errors": "^1.3.0",
@@ -7986,13 +8441,15 @@
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
 			"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
-			"dev": true
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/source-map-js": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
 			"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
 			"dev": true,
+			"license": "BSD-3-Clause",
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -8001,44 +8458,48 @@
 			"version": "0.0.2",
 			"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
 			"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/statuses": {
 			"version": "2.0.2",
 			"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
 			"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
 		},
 		"node_modules/std-env": {
-			"version": "3.10.0",
-			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
-			"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
-			"dev": true
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+			"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/string-width": {
-			"version": "7.2.0",
-			"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
-			"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+			"version": "8.2.2",
+			"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+			"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
+			"license": "MIT",
 			"dependencies": {
-				"emoji-regex": "^10.3.0",
-				"get-east-asian-width": "^1.0.0",
-				"strip-ansi": "^7.1.0"
+				"get-east-asian-width": "^1.5.0",
+				"strip-ansi": "^7.1.2"
 			},
 			"engines": {
-				"node": ">=18"
+				"node": ">=20"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
 		"node_modules/strip-ansi": {
-			"version": "7.1.2",
-			"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
-			"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+			"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+			"license": "MIT",
 			"dependencies": {
-				"ansi-regex": "^6.0.1"
+				"ansi-regex": "^6.2.2"
 			},
 			"engines": {
 				"node": ">=12"
@@ -8047,6 +8508,18 @@
 				"url": "https://github.com/chalk/strip-ansi?sponsor=1"
 			}
 		},
+		"node_modules/strip-ansi/node_modules/ansi-regex": {
+			"version": "6.3.0",
+			"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+			"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=12"
+			},
+			"funding": {
+				"url": "https://github.com/chalk/ansi-regex?sponsor=1"
+			}
+		},
 		"node_modules/strip-indent": {
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -8065,6 +8538,7 @@
 			"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
 			"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=8"
 			},
@@ -8072,17 +8546,12 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
-		"node_modules/sugar-high": {
-			"version": "1.1.0",
-			"resolved": "https://registry.npmjs.org/sugar-high/-/sugar-high-1.1.0.tgz",
-			"integrity": "sha512-pL68G9H5VgK5z5aRAp8Yl4+obwbfEpr4BCCdO9V9JrWTFQiPMuN2GKxl9Vn/oxzkcS8TOTFFNti3LXe0UWX2Yw==",
-			"license": "MIT"
-		},
 		"node_modules/supports-color": {
 			"version": "7.2.0",
 			"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
 			"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"has-flag": "^4.0.0"
 			},
@@ -8097,17 +8566,29 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/tailwind-merge": {
+			"version": "3.6.0",
+			"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+			"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/dcastil"
+			}
+		},
 		"node_modules/tailwindcss": {
-			"version": "4.1.18",
-			"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
-			"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
-			"dev": true
+			"version": "4.3.3",
+			"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+			"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/tapable": {
-			"version": "2.3.0",
-			"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
-			"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+			"version": "2.3.3",
+			"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+			"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=6"
 			},
@@ -8120,24 +8601,27 @@
 			"version": "2.9.0",
 			"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
 			"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/tinyexec": {
-			"version": "1.0.2",
-			"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
-			"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+			"version": "1.3.0",
+			"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+			"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			}
 		},
 		"node_modules/tinyglobby": {
-			"version": "0.2.15",
-			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
-			"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+			"version": "0.2.17",
+			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+			"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+			"license": "MIT",
 			"dependencies": {
 				"fdir": "^6.5.0",
-				"picomatch": "^4.0.3"
+				"picomatch": "^4.0.4"
 			},
 			"engines": {
 				"node": ">=12.0.0"
@@ -8147,31 +8631,32 @@
 			}
 		},
 		"node_modules/tinyrainbow": {
-			"version": "3.0.3",
-			"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
-			"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+			"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=14.0.0"
 			}
 		},
 		"node_modules/tldts": {
-			"version": "7.0.23",
-			"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
-			"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
+			"version": "7.4.11",
+			"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
+			"integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"tldts-core": "^7.0.23"
+				"tldts-core": "^7.4.11"
 			},
 			"bin": {
 				"tldts": "bin/cli.js"
 			}
 		},
 		"node_modules/tldts-core": {
-			"version": "7.0.23",
-			"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
-			"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
+			"version": "7.4.11",
+			"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
+			"integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
 			"dev": true,
 			"license": "MIT"
 		},
@@ -8179,14 +8664,15 @@
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
 			"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.6"
 			}
 		},
 		"node_modules/tough-cookie": {
-			"version": "6.0.0",
-			"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
-			"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+			"version": "6.0.2",
+			"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+			"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
 			"dev": true,
 			"license": "BSD-3-Clause",
 			"dependencies": {
@@ -8210,10 +8696,11 @@
 			}
 		},
 		"node_modules/ts-api-utils": {
-			"version": "2.4.0",
-			"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
-			"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+			"version": "2.5.0",
+			"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+			"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18.12"
 			},
@@ -8225,7 +8712,9 @@
 			"version": "3.1.6",
 			"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
 			"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
+			"deprecated": "unmaintained",
 			"dev": true,
+			"license": "MIT",
 			"bin": {
 				"tsconfck": "bin/tsconfck.js"
 			},
@@ -8244,13 +8733,17 @@
 		"node_modules/tslib": {
 			"version": "2.8.1",
 			"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-			"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+			"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+			"dev": true,
+			"license": "0BSD",
+			"optional": true
 		},
 		"node_modules/type-check": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
 			"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"prelude-ls": "^1.2.1"
 			},
@@ -8259,22 +8752,41 @@
 			}
 		},
 		"node_modules/type-is": {
-			"version": "2.0.1",
-			"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
-			"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+			"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+			"license": "MIT",
 			"dependencies": {
-				"content-type": "^1.0.5",
+				"content-type": "^2.0.0",
 				"media-typer": "^1.1.0",
 				"mime-types": "^3.0.0"
 			},
 			"engines": {
-				"node": ">= 0.6"
+				"node": ">= 18"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
+			}
+		},
+		"node_modules/type-is/node_modules/content-type": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+			"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=18"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/express"
 			}
 		},
 		"node_modules/typescript": {
 			"version": "5.9.3",
 			"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
 			"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+			"license": "Apache-2.0",
 			"bin": {
 				"tsc": "bin/tsc",
 				"tsserver": "bin/tsserver"
@@ -8284,15 +8796,16 @@
 			}
 		},
 		"node_modules/typescript-eslint": {
-			"version": "8.54.0",
-			"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz",
-			"integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==",
+			"version": "8.68.0",
+			"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
+			"integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/eslint-plugin": "8.54.0",
-				"@typescript-eslint/parser": "8.54.0",
-				"@typescript-eslint/typescript-estree": "8.54.0",
-				"@typescript-eslint/utils": "8.54.0"
+				"@typescript-eslint/eslint-plugin": "8.68.0",
+				"@typescript-eslint/parser": "8.68.0",
+				"@typescript-eslint/typescript-estree": "8.68.0",
+				"@typescript-eslint/utils": "8.68.0"
 			},
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8302,36 +8815,39 @@
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^8.57.0 || ^9.0.0",
-				"typescript": ">=4.8.4 <6.0.0"
+				"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+				"typescript": ">=4.8.4 <6.1.0"
 			}
 		},
 		"node_modules/uc.micro": {
-			"version": "2.1.0",
-			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
-			"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
+			"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
 			"license": "MIT"
 		},
 		"node_modules/undici": {
-			"version": "7.18.2",
-			"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
-			"integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
-			"dev": true,
+			"version": "6.28.0",
+			"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
+			"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
+			"license": "MIT",
+			"peer": true,
 			"engines": {
-				"node": ">=20.18.1"
+				"node": ">=18.17"
 			}
 		},
 		"node_modules/undici-types": {
 			"version": "6.21.0",
 			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
 			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/unenv": {
 			"version": "2.0.0-rc.24",
 			"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
 			"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"pathe": "^2.0.3"
 			}
@@ -8340,20 +8856,22 @@
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/unpipe": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
 			"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
 		},
 		"node_modules/update-browserslist-db": {
-			"version": "1.2.3",
-			"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
-			"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+			"version": "1.3.2",
+			"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+			"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
 			"dev": true,
 			"funding": [
 				{
@@ -8369,6 +8887,7 @@
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"dependencies": {
 				"escalade": "^3.2.0",
 				"picocolors": "^1.1.1"
@@ -8385,53 +8904,11 @@
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
 			"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
 			"dev": true,
+			"license": "BSD-2-Clause",
 			"dependencies": {
 				"punycode": "^2.1.0"
 			}
 		},
-		"node_modules/use-callback-ref": {
-			"version": "1.3.3",
-			"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
-			"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
-			"license": "MIT",
-			"dependencies": {
-				"tslib": "^2.0.0"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/use-sidecar": {
-			"version": "1.1.3",
-			"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
-			"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
-			"license": "MIT",
-			"dependencies": {
-				"detect-node-es": "^1.1.0",
-				"tslib": "^2.0.0"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/use-sync-external-store": {
 			"version": "1.6.0",
 			"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -8442,10 +8919,11 @@
 			}
 		},
 		"node_modules/valibot": {
-			"version": "1.2.0",
-			"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
-			"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
+			"version": "1.4.2",
+			"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
+			"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
 			"dev": true,
+			"license": "MIT",
 			"peerDependencies": {
 				"typescript": ">=5"
 			},
@@ -8459,17 +8937,19 @@
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
 			"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">= 0.8"
 			}
 		},
 		"node_modules/vite": {
-			"version": "7.3.1",
-			"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
-			"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+			"version": "7.3.6",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+			"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"esbuild": "^0.27.0",
+				"esbuild": "^0.27.0 || ^0.28.0",
 				"fdir": "^6.5.0",
 				"picomatch": "^4.0.3",
 				"postcss": "^8.5.6",
@@ -8542,6 +9022,7 @@
 			"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
 			"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"cac": "^6.7.14",
 				"debug": "^4.4.1",
@@ -8563,13 +9044,15 @@
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/vite-tsconfig-paths": {
 			"version": "5.1.4",
 			"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
 			"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"debug": "^4.1.1",
 				"globrex": "^0.1.2",
@@ -8585,30 +9068,31 @@
 			}
 		},
 		"node_modules/vitest": {
-			"version": "4.0.18",
-			"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
-			"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
-			"dev": true,
-			"dependencies": {
-				"@vitest/expect": "4.0.18",
-				"@vitest/mocker": "4.0.18",
-				"@vitest/pretty-format": "4.0.18",
-				"@vitest/runner": "4.0.18",
-				"@vitest/snapshot": "4.0.18",
-				"@vitest/spy": "4.0.18",
-				"@vitest/utils": "4.0.18",
-				"es-module-lexer": "^1.7.0",
-				"expect-type": "^1.2.2",
+			"version": "4.1.11",
+			"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+			"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@vitest/expect": "4.1.11",
+				"@vitest/mocker": "4.1.11",
+				"@vitest/pretty-format": "4.1.11",
+				"@vitest/runner": "4.1.11",
+				"@vitest/snapshot": "4.1.11",
+				"@vitest/spy": "4.1.11",
+				"@vitest/utils": "4.1.11",
+				"es-module-lexer": "^2.0.0",
+				"expect-type": "^1.3.0",
 				"magic-string": "^0.30.21",
 				"obug": "^2.1.1",
 				"pathe": "^2.0.3",
 				"picomatch": "^4.0.3",
-				"std-env": "^3.10.0",
+				"std-env": "^4.0.0-rc.1",
 				"tinybench": "^2.9.0",
 				"tinyexec": "^1.0.2",
 				"tinyglobby": "^0.2.15",
-				"tinyrainbow": "^3.0.3",
-				"vite": "^6.0.0 || ^7.0.0",
+				"tinyrainbow": "^3.1.0",
+				"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
 				"why-is-node-running": "^2.3.0"
 			},
 			"bin": {
@@ -8624,12 +9108,15 @@
 				"@edge-runtime/vm": "*",
 				"@opentelemetry/api": "^1.9.0",
 				"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
-				"@vitest/browser-playwright": "4.0.18",
-				"@vitest/browser-preview": "4.0.18",
-				"@vitest/browser-webdriverio": "4.0.18",
-				"@vitest/ui": "4.0.18",
+				"@vitest/browser-playwright": "4.1.11",
+				"@vitest/browser-preview": "4.1.11",
+				"@vitest/browser-webdriverio": "4.1.11",
+				"@vitest/coverage-istanbul": "4.1.11",
+				"@vitest/coverage-v8": "4.1.11",
+				"@vitest/ui": "4.1.11",
 				"happy-dom": "*",
-				"jsdom": "*"
+				"jsdom": "*",
+				"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
 			},
 			"peerDependenciesMeta": {
 				"@edge-runtime/vm": {
@@ -8650,6 +9137,12 @@
 				"@vitest/browser-webdriverio": {
 					"optional": true
 				},
+				"@vitest/coverage-istanbul": {
+					"optional": true
+				},
+				"@vitest/coverage-v8": {
+					"optional": true
+				},
 				"@vitest/ui": {
 					"optional": true
 				},
@@ -8658,14 +9151,25 @@
 				},
 				"jsdom": {
 					"optional": true
+				},
+				"vite": {
+					"optional": false
 				}
 			}
 		},
+		"node_modules/vitest/node_modules/es-module-lexer": {
+			"version": "2.3.2",
+			"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+			"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/vitest/node_modules/pathe": {
 			"version": "2.0.3",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 			"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/w3c-keyname": {
 			"version": "2.2.8",
@@ -8707,9 +9211,9 @@
 			}
 		},
 		"node_modules/whatwg-url": {
-			"version": "16.0.0",
-			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz",
-			"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==",
+			"version": "16.0.1",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+			"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
@@ -8725,6 +9229,7 @@
 			"version": "2.0.2",
 			"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
 			"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+			"license": "ISC",
 			"dependencies": {
 				"isexe": "^2.0.0"
 			},
@@ -8740,6 +9245,7 @@
 			"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
 			"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"siginfo": "^2.0.0",
 				"stackback": "0.0.2"
@@ -8756,16 +9262,18 @@
 			"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
 			"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=0.10.0"
 			}
 		},
 		"node_modules/workerd": {
-			"version": "1.20260205.0",
-			"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260205.0.tgz",
-			"integrity": "sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==",
+			"version": "1.20260828.1",
+			"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz",
+			"integrity": "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==",
 			"dev": true,
 			"hasInstallScript": true,
+			"license": "Apache-2.0",
 			"bin": {
 				"workerd": "bin/workerd"
 			},
@@ -8773,40 +9281,42 @@
 				"node": ">=16"
 			},
 			"optionalDependencies": {
-				"@cloudflare/workerd-darwin-64": "1.20260205.0",
-				"@cloudflare/workerd-darwin-arm64": "1.20260205.0",
-				"@cloudflare/workerd-linux-64": "1.20260205.0",
-				"@cloudflare/workerd-linux-arm64": "1.20260205.0",
-				"@cloudflare/workerd-windows-64": "1.20260205.0"
+				"@cloudflare/workerd-darwin-64": "1.20260828.1",
+				"@cloudflare/workerd-darwin-arm64": "1.20260828.1",
+				"@cloudflare/workerd-linux-64": "1.20260828.1",
+				"@cloudflare/workerd-linux-arm64": "1.20260828.1",
+				"@cloudflare/workerd-windows-64": "1.20260828.1"
 			}
 		},
 		"node_modules/wrangler": {
-			"version": "4.63.0",
-			"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.63.0.tgz",
-			"integrity": "sha512-+R04jF7Eb8K3KRMSgoXpcIdLb8GC62eoSGusYh1pyrSMm/10E0hbKkd7phMJO4HxXc6R7mOHC5SSoX9eof30Uw==",
+			"version": "4.127.1",
+			"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.127.1.tgz",
+			"integrity": "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==",
 			"dev": true,
+			"license": "MIT OR Apache-2.0",
 			"dependencies": {
-				"@cloudflare/kv-asset-handler": "0.4.2",
-				"@cloudflare/unenv-preset": "2.12.0",
+				"@cloudflare/kv-asset-handler": "0.5.0",
+				"@cloudflare/unenv-preset": "2.16.1",
 				"blake3-wasm": "2.1.5",
-				"esbuild": "0.27.0",
-				"miniflare": "4.20260205.0",
+				"esbuild": "0.28.1",
+				"miniflare": "5.20260828.0-alpha",
 				"path-to-regexp": "6.3.0",
 				"unenv": "2.0.0-rc.24",
-				"workerd": "1.20260205.0"
+				"workerd": "1.20260828.1"
 			},
 			"bin": {
+				"cf-wrangler": "bin/cf-wrangler.js",
 				"wrangler": "bin/wrangler.js",
 				"wrangler2": "bin/wrangler.js"
 			},
 			"engines": {
-				"node": ">=20.0.0"
+				"node": ">=22.0.0"
 			},
 			"optionalDependencies": {
-				"fsevents": "~2.3.2"
+				"fsevents": "2.3.3"
 			},
 			"peerDependencies": {
-				"@cloudflare/workers-types": "^4.20260205.0"
+				"@cloudflare/workers-types": "^5.20260828.1"
 			},
 			"peerDependenciesMeta": {
 				"@cloudflare/workers-types": {
@@ -8815,13 +9325,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/aix-ppc64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
-			"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+			"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"aix"
@@ -8831,13 +9342,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/android-arm": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
-			"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+			"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -8847,13 +9359,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/android-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
-			"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+			"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -8863,13 +9376,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/android-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
-			"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+			"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"android"
@@ -8879,13 +9393,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/darwin-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
-			"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+			"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -8895,13 +9410,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/darwin-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
-			"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+			"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"darwin"
@@ -8911,13 +9427,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
-			"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+			"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
@@ -8927,13 +9444,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/freebsd-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
-			"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+			"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"freebsd"
@@ -8943,13 +9461,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-arm": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
-			"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+			"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
 			"cpu": [
 				"arm"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -8959,13 +9478,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
-			"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+			"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -8975,13 +9495,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-ia32": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
-			"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+			"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -8991,13 +9512,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-loong64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
-			"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+			"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
 			"cpu": [
 				"loong64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9007,13 +9529,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-mips64el": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
-			"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+			"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
 			"cpu": [
 				"mips64el"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9023,13 +9546,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-ppc64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
-			"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+			"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
 			"cpu": [
 				"ppc64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9039,13 +9563,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-riscv64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
-			"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+			"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
 			"cpu": [
 				"riscv64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9055,13 +9580,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-s390x": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
-			"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+			"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
 			"cpu": [
 				"s390x"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9071,13 +9597,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/linux-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
-			"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+			"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"linux"
@@ -9087,13 +9614,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
-			"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+			"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"netbsd"
@@ -9103,13 +9631,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/netbsd-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
-			"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+			"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"netbsd"
@@ -9119,13 +9648,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
-			"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+			"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
@@ -9135,13 +9665,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/openbsd-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
-			"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+			"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openbsd"
@@ -9151,13 +9682,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
-			"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+			"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"openharmony"
@@ -9167,13 +9699,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/sunos-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
-			"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+			"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"sunos"
@@ -9183,13 +9716,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/win32-arm64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
-			"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+			"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
 			"cpu": [
 				"arm64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -9199,13 +9733,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/win32-ia32": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
-			"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+			"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
 			"cpu": [
 				"ia32"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -9215,13 +9750,14 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/@esbuild/win32-x64": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
-			"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+			"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
 			"cpu": [
 				"x64"
 			],
 			"dev": true,
+			"license": "MIT",
 			"optional": true,
 			"os": [
 				"win32"
@@ -9231,11 +9767,12 @@
 			}
 		},
 		"node_modules/wrangler/node_modules/esbuild": {
-			"version": "0.27.0",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
-			"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
+			"version": "0.28.1",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+			"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
 			"dev": true,
 			"hasInstallScript": true,
+			"license": "MIT",
 			"bin": {
 				"esbuild": "bin/esbuild"
 			},
@@ -9243,44 +9780,46 @@
 				"node": ">=18"
 			},
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.27.0",
-				"@esbuild/android-arm": "0.27.0",
-				"@esbuild/android-arm64": "0.27.0",
-				"@esbuild/android-x64": "0.27.0",
-				"@esbuild/darwin-arm64": "0.27.0",
-				"@esbuild/darwin-x64": "0.27.0",
-				"@esbuild/freebsd-arm64": "0.27.0",
-				"@esbuild/freebsd-x64": "0.27.0",
-				"@esbuild/linux-arm": "0.27.0",
-				"@esbuild/linux-arm64": "0.27.0",
-				"@esbuild/linux-ia32": "0.27.0",
-				"@esbuild/linux-loong64": "0.27.0",
-				"@esbuild/linux-mips64el": "0.27.0",
-				"@esbuild/linux-ppc64": "0.27.0",
-				"@esbuild/linux-riscv64": "0.27.0",
-				"@esbuild/linux-s390x": "0.27.0",
-				"@esbuild/linux-x64": "0.27.0",
-				"@esbuild/netbsd-arm64": "0.27.0",
-				"@esbuild/netbsd-x64": "0.27.0",
-				"@esbuild/openbsd-arm64": "0.27.0",
-				"@esbuild/openbsd-x64": "0.27.0",
-				"@esbuild/openharmony-arm64": "0.27.0",
-				"@esbuild/sunos-x64": "0.27.0",
-				"@esbuild/win32-arm64": "0.27.0",
-				"@esbuild/win32-ia32": "0.27.0",
-				"@esbuild/win32-x64": "0.27.0"
+				"@esbuild/aix-ppc64": "0.28.1",
+				"@esbuild/android-arm": "0.28.1",
+				"@esbuild/android-arm64": "0.28.1",
+				"@esbuild/android-x64": "0.28.1",
+				"@esbuild/darwin-arm64": "0.28.1",
+				"@esbuild/darwin-x64": "0.28.1",
+				"@esbuild/freebsd-arm64": "0.28.1",
+				"@esbuild/freebsd-x64": "0.28.1",
+				"@esbuild/linux-arm": "0.28.1",
+				"@esbuild/linux-arm64": "0.28.1",
+				"@esbuild/linux-ia32": "0.28.1",
+				"@esbuild/linux-loong64": "0.28.1",
+				"@esbuild/linux-mips64el": "0.28.1",
+				"@esbuild/linux-ppc64": "0.28.1",
+				"@esbuild/linux-riscv64": "0.28.1",
+				"@esbuild/linux-s390x": "0.28.1",
+				"@esbuild/linux-x64": "0.28.1",
+				"@esbuild/netbsd-arm64": "0.28.1",
+				"@esbuild/netbsd-x64": "0.28.1",
+				"@esbuild/openbsd-arm64": "0.28.1",
+				"@esbuild/openbsd-x64": "0.28.1",
+				"@esbuild/openharmony-arm64": "0.28.1",
+				"@esbuild/sunos-x64": "0.28.1",
+				"@esbuild/win32-arm64": "0.28.1",
+				"@esbuild/win32-ia32": "0.28.1",
+				"@esbuild/win32-x64": "0.28.1"
 			}
 		},
 		"node_modules/wrangler/node_modules/path-to-regexp": {
 			"version": "6.3.0",
 			"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
 			"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
-			"dev": true
+			"dev": true,
+			"license": "MIT"
 		},
 		"node_modules/wrap-ansi": {
 			"version": "9.0.2",
 			"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
 			"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+			"license": "MIT",
 			"dependencies": {
 				"ansi-styles": "^6.2.1",
 				"string-width": "^7.0.0",
@@ -9297,6 +9836,7 @@
 			"version": "6.2.3",
 			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
 			"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=12"
 			},
@@ -9304,16 +9844,35 @@
 				"url": "https://github.com/chalk/ansi-styles?sponsor=1"
 			}
 		},
+		"node_modules/wrap-ansi/node_modules/string-width": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+			"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+			"license": "MIT",
+			"dependencies": {
+				"emoji-regex": "^10.3.0",
+				"get-east-asian-width": "^1.0.0",
+				"strip-ansi": "^7.1.0"
+			},
+			"engines": {
+				"node": ">=18"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/sindresorhus"
+			}
+		},
 		"node_modules/wrappy": {
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-			"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+			"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+			"license": "ISC"
 		},
 		"node_modules/ws": {
-			"version": "8.18.0",
-			"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
-			"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+			"version": "8.21.0",
+			"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+			"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=10.0.0"
 			},
@@ -9371,6 +9930,7 @@
 			"version": "5.0.8",
 			"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
 			"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+			"license": "ISC",
 			"engines": {
 				"node": ">=10"
 			}
@@ -9379,12 +9939,13 @@
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
 			"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
-			"dev": true
+			"dev": true,
+			"license": "ISC"
 		},
 		"node_modules/yaml": {
-			"version": "2.8.2",
-			"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
-			"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+			"version": "2.9.0",
+			"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+			"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
 			"license": "ISC",
 			"bin": {
 				"yaml": "bin.mjs"
@@ -9397,14 +9958,15 @@
 			}
 		},
 		"node_modules/yargs": {
-			"version": "18.0.0",
-			"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
-			"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+			"version": "18.1.0",
+			"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
+			"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
+			"license": "MIT",
 			"dependencies": {
 				"cliui": "^9.0.1",
 				"escalade": "^3.1.1",
 				"get-caller-file": "^2.0.5",
-				"string-width": "^7.2.0",
+				"string-width": "^8.2.1",
 				"y18n": "^5.0.5",
 				"yargs-parser": "^22.0.0"
 			},
@@ -9416,14 +9978,15 @@
 			"version": "22.0.0",
 			"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
 			"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+			"license": "ISC",
 			"engines": {
 				"node": "^20.19.0 || ^22.12.0 || >=23"
 			}
 		},
 		"node_modules/yjs": {
-			"version": "13.6.29",
-			"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
-			"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
+			"version": "13.6.32",
+			"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz",
+			"integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==",
 			"license": "MIT",
 			"dependencies": {
 				"lib0": "^0.2.99"
@@ -9442,6 +10005,7 @@
 			"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
 			"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=10"
 			},
@@ -9454,6 +10018,7 @@
 			"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
 			"integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@poppinss/colors": "^4.1.5",
 				"@poppinss/dumper": "^0.6.4",
@@ -9467,6 +10032,7 @@
 			"resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
 			"integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@poppinss/exception": "^1.2.2",
 				"error-stack-parser-es": "^1.0.5"
@@ -9477,6 +10043,7 @@
 			"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
 			"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18"
 			},
@@ -9486,28 +10053,31 @@
 			}
 		},
 		"node_modules/zod": {
-			"version": "4.3.6",
-			"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
-			"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+			"version": "4.5.4",
+			"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
+			"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
+			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/colinhacks"
 			}
 		},
 		"node_modules/zod-to-json-schema": {
-			"version": "3.25.1",
-			"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
-			"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+			"version": "3.25.2",
+			"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+			"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+			"license": "ISC",
 			"peerDependencies": {
-				"zod": "^3.25 || ^4"
+				"zod": "^3.25.28 || ^4"
 			}
 		},
 		"node_modules/zod-to-ts": {
-			"version": "2.0.0",
-			"resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-2.0.0.tgz",
-			"integrity": "sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw==",
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-2.1.0.tgz",
+			"integrity": "sha512-jZP1GokTqR99FLmtGU+B9acjD9u/R++b++Vxe1sWpBQDd82/9/j5LyLZZ7/Oy3h2nxcz5NihikgX4D0hhdu3+g==",
+			"license": "MIT",
 			"peer": true,
 			"peerDependencies": {
-				"typescript": "^5.0.0",
+				"typescript": "^5 || ^6",
 				"zod": "^3.25.0 || ^4.0.0"
 			}
 		},
@@ -9516,6 +10086,7 @@
 			"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
 			"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
 				"node": ">=18.0.0"
 			},
diff --git a/package.json b/package.json
index 49158cb9..e4f93f3b 100644
--- a/package.json
+++ b/package.json
@@ -15,32 +15,32 @@
 		"typecheck": "npm run cf-typegen && react-router typegen && tsc -b"
 	},
 	"dependencies": {
-		"@radix-ui/react-dropdown-menu": "^2.1.16",
-		"@radix-ui/react-switch": "^1.2.6",
-		"@tiptap/core": "^3.19.0",
-		"@tiptap/extension-bubble-menu": "^3.19.0",
-		"@tiptap/extension-collaboration": "^3.19.0",
-		"@tiptap/extension-collaboration-caret": "^3.19.0",
-		"@tiptap/extension-document": "^3.19.0",
-		"@tiptap/extension-paragraph": "^3.19.0",
-		"@tiptap/extension-text": "^3.19.0",
-		"@tiptap/pm": "^3.19.0",
-		"@tiptap/react": "^3.19.0",
+		"@base-ui/react": "^1.7.0",
+		"@modelcontextprotocol/sdk": "1.25.2",
+		"@tiptap/core": "3.30.5",
+		"@tiptap/extension-bubble-menu": "3.30.5",
+		"@tiptap/extension-collaboration": "3.30.5",
+		"@tiptap/extension-collaboration-caret": "3.30.5",
+		"@tiptap/pm": "3.30.5",
+		"@tiptap/react": "3.30.5",
+		"@tiptap/starter-kit": "3.30.5",
 		"@tiptap/y-tiptap": "^3.0.2",
 		"agents": "^0.3.6",
+		"clsx": "^2.1.1",
 		"critic-markup": "^2.0.0",
-		"dompurify": "^3.3.3",
 		"fathom-client": "^3.7.2",
 		"isbot": "^5.1.31",
 		"lib0": "^0.2.117",
-		"marked": "^17.0.1",
+		"markdown-it": "15.0.1",
+		"prosemirror-markdown": "1.13.6",
 		"react": "^19.1.1",
 		"react-dom": "^19.1.1",
 		"react-router": "^7.10.0",
-		"sugar-high": "^1.1.0",
+		"tailwind-merge": "^3.6.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",
@@ -49,7 +49,6 @@
 		"@tailwindcss/vite": "^4.1.13",
 		"@testing-library/jest-dom": "^6.9.1",
 		"@testing-library/react": "^16.3.2",
-		"@types/dompurify": "^3.0.5",
 		"@types/node": "^22.19.9",
 		"@types/react": "^19.1.13",
 		"@types/react-dom": "^19.1.9",
@@ -64,5 +63,9 @@
 		"vite-tsconfig-paths": "^5.1.4",
 		"vitest": "^4.0.18",
 		"wrangler": "^4.63.0"
+	},
+	"overrides": {
+		"@tiptap/extension-floating-menu": "3.30.5",
+		"@tiptap/extension-bubble-menu": "3.30.5"
 	}
 }
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 00000000..b3114ee2
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
new file mode 100644
index 00000000..c28572a3
Binary files /dev/null and b/public/favicon-32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index 5dbdfcdd..13aeeeb6 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/logo-512.png b/public/logo-512.png
new file mode 100644
index 00000000..40c47d1f
Binary files /dev/null and b/public/logo-512.png differ
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 00000000..cfba6f62
Binary files /dev/null and b/public/logo.png differ
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"
+  }
+}
diff --git a/tests/helpers/document-context.tsx b/tests/helpers/document-context.tsx
index 032b02bc..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(),
@@ -50,12 +51,11 @@ export function createMockDocumentContext(
     editorInstance: null,
     markdown: "",
     mode: "edit",
+    setMode: vi.fn(),
     toggleMode: vi.fn(),
     showPreview: false,
     togglePreview: vi.fn(),
     setPreviewHeld: vi.fn(),
-    cleanView: true,
-    toggleCleanView: vi.fn(),
     commentActive: false,
     commentSelection: null,
     commentHighlight: null,
diff --git a/tests/integration/agents/document-agent.test.ts b/tests/integration/agents/document-agent.test.ts
index 6b09b426..6a3e494e 100644
--- a/tests/integration/agents/document-agent.test.ts
+++ b/tests/integration/agents/document-agent.test.ts
@@ -15,6 +15,8 @@ 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, type AgentIdentity } from "~/shared/agent-protocol";
+import { yDocToMarkdown } from "~/shared/rich-markdown";
 
 /* ------------------------------------------------------------------ */
 /*  Mock Agent base class                                              */
@@ -23,6 +25,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 `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.
+ */
+let mockTables: Map>>;
 
 vi.mock("agents", () => ({
   Agent: class MockAgent {
@@ -36,20 +46,36 @@ 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 query = strings.join("$").toLowerCase().trim();
+      const raw = strings.join("$");
+      const query = raw.toLowerCase().trim();
 
       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 [];
@@ -60,7 +86,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),
             );
@@ -69,11 +95,108 @@ 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 (!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);
+          if (colsMatch) {
+            const cols = colsMatch[1].split(",").map((c) => c.trim());
+            const row: Record = {};
+            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;
+            }
+            // Real SQLite rejects a duplicate PRIMARY KEY / UNIQUE value with
+            // a constraint error, and code that assumes ids are free (e.g.
+            // the performance-queue id counter resetting after an eviction
+            // that left rows behind) is only wrong if the fake enforces that
+            // too. Mirror the two constraints the schema actually declares
+            // and the DO code actually supplies values for.
+            const constrained: Record = {
+              performances: "id",
+              roster: "name",
+            };
+            const uniqueCol = constrained[table];
+            if (uniqueCol && rows.some((r) => r[uniqueCol] === row[uniqueCol])) {
+              throw new Error(
+                `UNIQUE constraint failed: ${table}.${uniqueCol} (${String(row[uniqueCol])})`,
+              );
+            }
+            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];
+            this._tables.set(
+              table,
+              rows.filter((row) => row[whereMatch[1]] !== whereVal),
+            );
+          } else {
+            this._tables.set(table, []);
+          }
+          return [];
+        }
+
+        if (query.startsWith("select")) {
+          // 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) {
+            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 [];
     }
 
     getConnections() {
-      return mockConnectionMap.values();
+      return this._connections.values();
     }
   },
 }));
@@ -154,6 +277,7 @@ describe("DocumentAgent", () => {
     vi.stubGlobal("WebSocket", MockSocket);
     mockSqlStore = new Map();
     mockConnectionMap = new Map();
+    mockTables = new Map();
     mockSetAlarm = vi.fn();
     nextConnId = 1;
 
@@ -175,6 +299,38 @@ 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)
+   * 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.
    *
@@ -224,6 +380,24 @@ describe("DocumentAgent", () => {
     }
   }
 
+  /**
+   * Waits for a call under test to register its (faked) setTimeout before
+   * 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++) {
+      await new Promise((resolve) => setImmediate(resolve));
+    }
+  }
+
   /* ================================================================ */
   /*  HTTP GET                                                         */
   /* ================================================================ */
@@ -319,17 +493,19 @@ describe("DocumentAgent", () => {
       cleanup(client);
     });
 
-    it("imports multiline content as separate paragraphs", async () => {
+    it("imports blank-line-separated content as separate blocks", async () => {
       await agent.onRequest(
         new Request("https://do/", {
           method: "POST",
           headers: { "Content-Type": "application/json" },
-          body: JSON.stringify({ content: "line one\nline two\nline three" }),
+          body: JSON.stringify({ content: "line one\n\nline two\n\n# line three" }),
         }),
       );
 
       const client = connectYjsClient();
-      expect(client.doc.getXmlFragment("default").length).toBe(3);
+      const frag = client.doc.getXmlFragment("default");
+      expect(frag.length).toBe(3);
+      expect((frag.get(2) as Y.XmlElement).nodeName).toBe("heading");
       cleanup(client);
     });
 
@@ -351,7 +527,9 @@ describe("DocumentAgent", () => {
       cleanup(client);
     });
 
-    it("returns 400 for unsupported CriticMarkup (substitution)", async () => {
+    it("imports CriticMarkup substitution as literal text", async () => {
+      // Substitution has no mark form; the markdown parser leaves its
+      // syntax in place as plain text rather than rejecting the import.
       const res = await agent.onRequest(
         new Request("https://do/", {
           method: "POST",
@@ -359,10 +537,16 @@ describe("DocumentAgent", () => {
           body: JSON.stringify({ content: "hello {~~old~>new~~}" }),
         }),
       );
-      expect(res.status).toBe(400);
-      const body = (await res.json()) as { ok: boolean; error: string };
-      expect(body.ok).toBe(false);
-      expect(body.error).toContain("Unsupported CriticMarkup");
+      expect(res.status).toBe(200);
+      const client = connectYjsClient();
+      const para = client.doc.getXmlFragment("default").get(0) as Y.XmlElement;
+      // The inner ~~…~~ pair reads as GFM strikethrough; the braces stay text.
+      expect((para.get(0) as Y.XmlText).toDelta()).toEqual([
+        { insert: "hello {" },
+        { insert: "old~>new", attributes: { strike: {} } },
+        { insert: "}" },
+      ]);
+      cleanup(client);
     });
 
     it("still creates doc even with malformed JSON body", async () => {
@@ -466,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
@@ -527,6 +714,65 @@ describe("DocumentAgent", () => {
       expect(b.doc.getText("default").toString()).toBe("hello ");
       cleanup(a, b);
     });
+
+    it("propagates an instant agent mutation to an already-connected client without reconnecting", async () => {
+      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: ["write"] });
+
+      const client = connectYjsClient(agent);
+      expect(yDocToMarkdown(client.doc)).toBe("# Title\n\nBody.");
+
+      const result = await agent.agentInsert(id, {
+        where: "append",
+        markdown: "Agent wrote this.",
+        pace: "instant",
+      });
+      expect(result).toEqual({ ok: true });
+
+      // No reconnect, no re-sync — the client's live replica must already
+      // reflect the agent's mutation via a broadcast update.
+      expect(yDocToMarkdown(client.doc)).toContain("Agent wrote this.");
+      cleanup(client);
+    });
+
+    it("propagates a paced (natural) agent mutation to a connected client tick by tick", async () => {
+      vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+      try {
+        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: ["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(id, {
+          where: "append",
+          markdown: "Typed live to the client.",
+          pace: "natural",
+        });
+        expect(result).toEqual({ ok: true });
+
+        await vi.runAllTimersAsync();
+
+        expect(yDocToMarkdown(client.doc)).toContain("Typed live to the client.");
+        cleanup(client);
+      } finally {
+        vi.useRealTimers();
+      }
+    });
   });
 
   /* ================================================================ */
@@ -559,4 +805,1458 @@ describe("DocumentAgent", () => {
       await agent.onClose(conn as never, 1000, "normal", true);
     });
   });
+
+  /* ================================================================ */
+  /*  Agent identity roster (implicit enrollment)                      */
+  /* ================================================================ */
+
+  describe("agent roster", () => {
+    it("enrolls a new identity on its first RPC call and lists it", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+
+      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 denied = await agent.agentInsert(id, { where: "append", markdown: "x" });
+      expect(denied).toMatchObject({ error: { code: "capability_denied" } });
+
+      await agent.revokeAgentEntry("scribe");
+      expect(await agent.getAgentRoster()).toHaveLength(0);
+    });
+
+    it("enrolls distinct identities into distinct roster rows, oldest first", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+
+      for (let i = 0; i < 3; i++) {
+        await agent.agentJoin(identity({ id: `email:agent-${i}@x.com`, name: `agent-${i}` }));
+      }
+
+      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++) {
+        const id = identity({ id: `email:agent-${i}@x.com`, name: `agent-${i}` });
+        expect(await agent.agentJoin(id)).toEqual({ ok: true });
+      }
+
+      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.revokeAgentEntry("agent-0");
+      expect(await agent.agentJoin(oneTooMany)).toEqual({ ok: true });
+    });
+
+    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 a malformed identity", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+      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 id = identity({ caps: ["suggest", "comment"] });
+
+      const read = await agent.agentRead(id);
+      expect("markdown" in read).toBe(true);
+
+      const [entry] = await agent.getAgentRoster();
+      expect(entry.lastSeenAt).not.toBeNull();
+    });
+
+    it("updates lastSeenAt even when the capability check denies the call", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+      const id = identity({ caps: ["suggest", "comment"] }); // no write
+      expect(await agent.getAgentRoster()).toHaveLength(0);
+
+      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.
+      expect((await agent.getAgentRoster())[0].lastSeenAt).not.toBeNull();
+    });
+
+    it("assigns roster colors round-robin by roster size", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+      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("enrolls an anonymous identity with owner null and its given capabilities", async () => {
+      await agent.onRequest(new Request("https://do/", { method: "POST" }));
+
+      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,
+        capabilities: ["suggest", "comment"],
+      });
+    });
+
+    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.alarm();
+
+      expect(await agent.getAgentRoster()).toEqual([]);
+
+      // 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" }));
+      expect(await agent.agentJoin(id)).toEqual({ ok: true });
+      expect(await agent.getAgentRoster()).toHaveLength(1);
+    });
+  });
+
+  /* ================================================================ */
+  /*  Agent read + instant mutations                                   */
+  /* ================================================================ */
+
+  describe("agent mutations", () => {
+    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 id = identity({ caps });
+      return { agent, id };
+    }
+
+    it("reads markdown with anchors", async () => {
+      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(/^[a-z0-9]{8}-[0-9a-f]{8}$/);
+    });
+
+    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." });
+    });
+
+    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, 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, 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, id } = await setup(["suggest"]);
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[1].anchor;   // "Body."
+      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, id } = await setup(["write"]);
+
+      const result = await agent.agentInsert(id, { where: "after", markdown: "Orphan." });
+      expect(result).toMatchObject({ error: { code: "stale_anchor" } });
+
+      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, id } = await setup(["write"]);
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+      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, 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(id, { where: "append", markdown: "Same\n\nOther\n\nSame\n\nEnd" });
+
+      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");
+      expect(sameBlocks).toHaveLength(2); // real indices 2 and 4
+
+      const hash = sameBlocks[0].anchor.split("-").pop()!;
+      // "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(id, { from: fromAnchor, to: toAnchor, markdown: "Nope" });
+      expect(result).toMatchObject({ error: { code: "stale_anchor" } });
+
+      const after = await agent.agentRead(id);
+      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, 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(id);
+        expect("markdown" in read && read.markdown).toContain("Typed live.");
+      });
+
+      it("applies instantly regardless of pace when pace is 'instant'", async () => {
+        const { agent, id } = await setup(["write"]);
+        createConnection();
+        const result = await agent.agentInsert(id, {
+          where: "append",
+          markdown: "Pasted in.",
+          pace: "instant",
+        });
+        expect(result).toEqual({ ok: true });
+
+        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, 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(id, {
+          where: "append",
+          markdown: fullText,
+          pace: "natural",
+        });
+        expect(result).toEqual({ ok: true });
+
+        // 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(id);
+        const blocksBefore = "blocks" in beforeAnyTick ? beforeAnyTick.blocks : [];
+        expect(blocksBefore[2]?.text ?? "").toBe("");
+
+        // Advance past at least the first tick (maximum natural-pace delay
+        // is 320ms), but nowhere near enough for the fastest possible full
+        // typing (78 chars / 4 chars-per-tick max * 180ms-per-tick min =
+        // 3510ms) — so this is genuinely partial, not a fluke of timing.
+        await vi.advanceTimersByTimeAsync(400);
+
+        const afterFirstTick = await agent.agentRead(id);
+        const partialBlock = ("blocks" in afterFirstTick ? afterFirstTick.blocks : [])[2];
+        const partialLength = partialBlock?.text.length ?? 0;
+        expect(partialLength).toBeGreaterThan(0);
+        expect(partialLength).toBeLessThan(fullText.length);
+
+        await vi.runAllTimersAsync();
+
+        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, 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(id, {
+          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(id, {
+          where: "append",
+          markdown: "Recovered text.",
+          pace: "natural",
+        });
+
+        // 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(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, id } = await setup(["write"]);
+        createConnection();
+
+        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(id, {
+          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(id2, {
+          where: "append",
+          markdown: "Instant line",
+          pace: "instant",
+        });
+        expect(instantResult).toEqual({ ok: true });
+
+        await vi.runAllTimersAsync();
+
+        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);
+
+        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, id } = await setup(["write"]);
+        createConnection();
+
+        const id2 = identity({ id: "email:bot2@x.com", name: "bot2", caps: ["write"] });
+
+        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(id, {
+          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(id2, {
+          where: "before",
+          anchor: anchor0,
+          markdown: "Preamble",
+          pace: "instant",
+        });
+        expect(instantResult).toEqual({ ok: true });
+
+        await vi.runAllTimersAsync();
+
+        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);
+
+        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, id } = await setup(["write"]);
+        createConnection();
+
+        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(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(id, {
+          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(id, {
+          from: anchor0,
+          markdown: "Changed first!",
+          pace: "instant",
+        });
+        expect(instantEdit).toEqual({ ok: true });
+
+        await vi.runAllTimersAsync();
+
+        const after = await agent.agentRead(id);
+        const markdown = "markdown" in after ? after.markdown : "";
+        expect(markdown).toContain("Changed first!");
+        expect(markdown).not.toContain("Replaced!");
+      });
+    });
+
+    /* ================================================================ */
+    /*  Unsupported markup: errors as values, never a throw              */
+    /* ================================================================ */
+
+    describe("substitution markup", () => {
+      /**
+       * CriticMarkup substitution has no mark form. The markdown parser
+       * leaves its syntax in place as literal text — mutations succeed and
+       * the syntax reads back verbatim (unsupported_markup remains only as
+       * the parser-failure backstop).
+       */
+      const SUBSTITUTION = "A {~~old~>new~~} B";
+
+      /**
+       * The performance queue's internals. Tests reach in to plant the kind
+       * of payload the RPCs now reject up front, standing in for a row
+       * written by an older build (or corrupted in storage).
+       */
+      function asQueue(a: InstanceType) {
+        return a as unknown as {
+          enqueuePerformance(name: string, pace: string, mutation: unknown): { ok: true };
+          isPerforming: boolean;
+        };
+      }
+
+      afterEach(() => {
+        vi.useRealTimers();
+      });
+
+      it("agentInsert imports substitution syntax as literal text", async () => {
+        const { agent, id } = await setup(["write"]);
+
+        const result = await agent.agentInsert(id, {
+          where: "append",
+          markdown: SUBSTITUTION,
+          pace: "instant",
+        });
+
+        expect(result).toEqual({ ok: true });
+        const after = await agent.agentRead(id);
+        expect("markdown" in after && after.markdown).toContain("{~~old\\~>new~~}");
+      });
+
+      it("agentReplace with substitution text keeps the document consistent", async () => {
+        const { agent, id } = await setup(["write"]);
+        const before = await agent.agentRead(id);
+        const anchor = ("blocks" in before ? before.blocks : [])[0].anchor;
+
+        const result = await agent.agentReplace(id, {
+          from: anchor,
+          markdown: SUBSTITUTION,
+          pace: "instant",
+        });
+
+        expect(result).toEqual({ ok: true });
+        const after = await agent.agentRead(id);
+        expect("markdown" in after && after.markdown).toContain("{~~old\\~>new~~}");
+        expect("markdown" in after && after.markdown).toContain("Body.");
+      });
+
+      it("types a queued substitution as literal text and drains the queue", async () => {
+        vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+        const { agent, id } = await setup(["write"]);
+        createConnection();
+
+        asQueue(agent).enqueuePerformance("scribe", "fast", {
+          kind: "insert",
+          where: "append",
+          markdown: SUBSTITUTION,
+        });
+        await agent.agentInsert(id, {
+          where: "append",
+          markdown: "Good text.",
+          pace: "fast",
+        });
+
+        await vi.runAllTimersAsync();
+
+        const after = await agent.agentRead(id);
+        const markdown = "markdown" in after ? after.markdown : "";
+        expect(markdown).toContain("Good text.");
+        expect(markdown).toContain("old\\~>new");
+        expect(asQueue(agent).isPerforming).toBe(false);
+        expect(mockTables.get("performances") ?? []).toEqual([]);
+      });
+
+      it("does not wedge the queue when a queued mutation throws", async () => {
+        vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+        const { agent, id } = await setup(["write"]);
+        createConnection();
+
+        // A payload no code path can apply: `markdown` isn't a string, so
+        // the first thing performTypedInsert does with it throws. Before the
+        // runner caught this, isPerforming stayed true forever and every
+        // later mutation queued behind it was never performed.
+        asQueue(agent).enqueuePerformance("scribe", "fast", {
+          kind: "insert",
+          where: "append",
+          markdown: null,
+        });
+        await vi.runAllTimersAsync();
+
+        expect(asQueue(agent).isPerforming).toBe(false);
+        expect(mockTables.get("performances") ?? []).toEqual([]);
+
+        await agent.agentInsert(id, {
+          where: "append",
+          markdown: "Still working.",
+          pace: "fast",
+        });
+        await vi.runAllTimersAsync();
+
+        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, 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
+        // doc/awareness already set, leaving the Yjs observers unregistered
+        // (no mentions, no events, ever) and the row alive to collide with a
+        // performance id counter that restarts at 1.
+        mockTables.set("performances", [
+          { id: 1, agent_name: "scribe", kind: "insert", payload: "{ not json", created_at: Date.now() },
+        ]);
+
+        const agent2 = new DocumentAgent({} as never, {} as never);
+        const read = await agent2.agentRead(id);
+        expect("markdown" in read).toBe(true);
+        expect(mockTables.get("performances") ?? []).toEqual([]);
+
+        // Observers registered: a human mention still fires.
+        const client = connectYjsClient(agent2);
+        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(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(id, {
+          where: "append",
+          markdown: "After recovery.",
+          pace: "natural",
+        });
+        expect(queued).toEqual({ ok: true });
+        cleanup(client);
+        void agent;
+      });
+    });
+
+    /* ================================================================ */
+    /*  Corrupt stored JSON: typed errors, never a throw                 */
+    /* ================================================================ */
+
+    describe("corrupt stored state", () => {
+      it("agentRead skips an unparseable thread rather than throwing", async () => {
+        const { agent, id } = await setup(["comment"]);
+        const read = await agent.agentRead(id);
+        const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+        await agent.agentComment(id, { anchor, text: "fine" });
+
+        const client = connectYjsClient(agent);
+        client.doc.getMap("threads").set("broken", "{ not json");
+
+        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, id } = await setup(["comment"]);
+        const client = connectYjsClient(agent);
+        client.doc.getMap("threads").set("broken", "{ not json");
+
+        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, 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(id, {
+          where: "append",
+          markdown: "Fine.",
+          pace: "instant",
+        });
+
+        expect(result).toEqual({ ok: true });
+        expect(JSON.parse(row.recent_mutations as string)).toHaveLength(1);
+      });
+    });
+  });
+
+  /* ================================================================ */
+  /*  Agent presence in awareness                                      */
+  /* ================================================================ */
+
+  describe("agent presence", () => {
+    afterEach(() => {
+      vi.useRealTimers();
+    });
+
+    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 id = identity({ caps });
+      return { agent, id };
+    }
+
+    /** 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, id } = await setup();
+      const a = connectYjsClient(agent);
+      const b = connectYjsClient(agent);
+
+      const result = await agent.agentJoin(id, "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, id } = await setup();
+      await agent.agentJoin(id);
+
+      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, id } = await setup();
+      const a = connectYjsClient(agent);
+      await agent.agentJoin(id);
+      expect(findAgentState(a.awareness)).toBeDefined();
+
+      const result = await agent.agentLeave(id);
+      expect(result).toEqual({ ok: true });
+      expect(findAgentState(a.awareness)).toBeUndefined();
+      cleanup(a);
+    });
+
+    it("rejects join/leave for a malformed identity", async () => {
+      const { agent } = await setup();
+      expect(await agent.agentJoin({} as never)).toMatchObject({
+        error: { code: "invalid_token" },
+      });
+      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, id } = await setup();
+      const a = connectYjsClient(agent);
+      await agent.agentJoin(id);
+
+      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, id } = await setup(["write"]);
+      const a = connectYjsClient(agent);
+      await agent.agentJoin(id);
+
+      // 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(id, { 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, 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(id, { 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, id } = await setup();
+      const a = connectYjsClient(agent);
+      await agent.agentJoin(id);
+      expect(findAgentState(a.awareness)).toBeDefined();
+
+      await agent.alarm();
+
+      mockConnectionMap.clear();
+      const b = connectYjsClient(agent);
+      expect(findAgentState(b.awareness)).toBeUndefined();
+      cleanup(a, b);
+    });
+  });
+
+  /* ================================================================ */
+  /*  Events: mentions, thread replies, await_events long-poll         */
+  /* ================================================================ */
+
+  describe("agent events", () => {
+    afterEach(() => {
+      vi.useRealTimers();
+    });
+
+    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 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, id } = 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(id, {});
+      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("records exactly one mention when a human types @scribe one character at a time", async () => {
+      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;
+      // Real typing: one Yjs transaction per keystroke. No single delta op
+      // ever contains "@scribe", so per-op matching never fires at all.
+      for (const ch of " @scribe") {
+        ytext.insert(ytext.length, ch);
+      }
+
+      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);
+      expect(mentions[0].payload).toMatchObject({
+        agent: "scribe",
+        text: expect.stringContaining("@scribe"),
+      });
+
+      // Typing on in the same block must not re-fire the same mention.
+      const cursor = "cursor" in result ? result.cursor : 0;
+      for (const ch of ", please") {
+        ytext.insert(ytext.length, ch);
+      }
+      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);
+
+      cleanup(client);
+    });
+
+    it("re-fires a mention after it is deleted and retyped", async () => {
+      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;
+      const base = ytext.length;
+      for (const ch of " @scribe") {
+        ytext.insert(ytext.length, ch);
+      }
+      const first = await agent.agentAwaitEvents(id, {});
+      const cursor = "cursor" in first ? first.cursor : 0;
+
+      ytext.delete(base, ytext.length - base);
+      for (const ch of " @scribe") {
+        ytext.insert(ytext.length, ch);
+      }
+
+      const second = await agent.agentAwaitEvents(id, { cursor });
+      const mentions = ("events" in second ? second.events : []).filter(
+        (e) => e.type === "mention",
+      );
+      expect(mentions).toHaveLength(1);
+      cleanup(client);
+    });
+
+    it("records no events at all while the roster is empty", async () => {
+      const agent = makeAgent();
+      await agent.onRequest(new Request("https://do/", {
+        method: "POST", headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: "Hello there." }),
+      }));
+      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, " a human edit");
+
+      // doc_changed digests used to be recorded above the roster check, so
+      // every agentless document accrued rows nobody could ever read.
+      expect(mockTables.get("events") ?? []).toEqual([]);
+
+      // With an agent on the roster, the digest is recorded as before.
+      await agent.agentJoin(identity());
+      ytext.insert(ytext.length, " and another");
+      expect((mockTables.get("events") ?? []).map((r) => r.type)).toContain("doc_changed");
+
+      cleanup(client);
+    });
+
+    it("delivers a mention only to the agent it names", async () => {
+      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;
+      const ytext = para.get(0) as Y.XmlText;
+      for (const ch of " @scribe") {
+        ytext.insert(ytext.length, ch);
+      }
+
+      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(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.
+      expect(museEvents.some((e) => e.type === "doc_changed")).toBe(true);
+      // ...and muse's cursor advanced past scribe's mention regardless.
+      expect("cursor" in forMuse && forMuse.cursor).toBe(
+        "cursor" in forScribe ? forScribe.cursor : -1,
+      );
+
+      cleanup(client);
+    });
+
+    it("delivers a thread_reply only to the agent that authored the thread", async () => {
+      const { agent, id } = await setup(["comment"]);
+      const museId = identity({ id: "email:muse@x.com", name: "muse", caps: ["comment"] });
+
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+      const created = await agent.agentComment(id, { anchor, text: "needs work" });
+      const threadId = "threadId" in created ? created.threadId : "";
+
+      const client = connectYjsClient(agent);
+      const threadsMap = client.doc.getMap("threads");
+      const thread = JSON.parse(threadsMap.get(threadId)!);
+      thread.replies.push({
+        id: "r1",
+        author: { name: "Nick", color: "#000", colorLight: "#000" },
+        text: "thanks!",
+        createdAt: Date.now(),
+      });
+      threadsMap.set(threadId, JSON.stringify(thread));
+
+      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(museId, { timeoutMs: 20 });
+      expect(("events" in forMuse ? forMuse.events : []).some((e) => e.type === "thread_reply"))
+        .toBe(false);
+
+      cleanup(client);
+    });
+
+    it("resolves empty after the timeout when no events occur (fake timers)", async () => {
+      const { agent, id } = await setup();
+      vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+      const promise = agent.agentAwaitEvents(id, { timeoutMs: 50 });
+      await waitForTimerRegistered();
+      await vi.advanceTimersByTimeAsync(50);
+      const result = await promise;
+
+      expect(result).toEqual({ events: [], cursor: 0, retryAfterMs: 30_000 });
+    });
+
+    it("excludes already-seen events once the cursor advances past them", async () => {
+      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(id, {});
+      const cursor = "cursor" in first ? first.cursor : -1;
+      expect(cursor).toBeGreaterThan(0);
+
+      vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+      const secondPromise = agent.agentAwaitEvents(id, { cursor, timeoutMs: 50 });
+      await waitForTimerRegistered();
+      await vi.advanceTimersByTimeAsync(50);
+      const second = await secondPromise;
+
+      expect(second).toEqual({ events: [], cursor, retryAfterMs: 30_000 });
+      cleanup(client);
+    });
+
+    it("round-trips a comment and reply, and records a thread_reply event for a human reply", async () => {
+      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(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(id);
+      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(id, {});
+      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, id } = await setup([]);
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+      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, id } = await setup(["comment"]);
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+      const created = await agent.agentComment(id, { anchor, text: "hi" });
+      const threadId = "threadId" in created ? created.threadId : "";
+
+      const result = await agent.agentReply(id, { threadId, text: "reply text" });
+      expect(result).toEqual({ ok: true });
+
+      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, 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, id } = await setup(["comment"]);
+      const read = await agent.agentRead(id);
+      const anchor = ("blocks" in read ? read.blocks : [])[0].anchor;
+      const created = await agent.agentComment(id, { 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(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;
+
+      // 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(id, { 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, 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 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(id, { anchor, text: "hi" });
+      expect(commentResult).toMatchObject({ error: { code: "rate_limited" } });
+
+      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, 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(id, {});
+      cleanup(client);
+
+      await agent.alarm();
+
+      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/integration/agents/registry.test.ts b/tests/integration/agents/registry.test.ts
new file mode 100644
index 00000000..40234b44
--- /dev/null
+++ b/tests/integration/agents/registry.test.ts
@@ -0,0 +1,130 @@
+/**
+ * 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:ada@example.com", {
+      displayName: "Ada L",
+    });
+    expect(profile.uid).toBeTruthy();
+    expect(profile.agentSlug).toBeNull();
+
+    const slug = await reg.ensureAgentSlug("email:ada@example.com");
+    expect(slug).toEqual({ slug: "ada-l" });
+
+    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("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: "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 () => {
+    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",
+      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",
+      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" } });
+    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/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);
+  });
+});
diff --git a/tests/unit/agents/mcp-tools.test.ts b/tests/unit/agents/mcp-tools.test.ts
new file mode 100644
index 00000000..f04a6264
--- /dev/null
+++ b/tests/unit/agents/mcp-tools.test.ts
@@ -0,0 +1,230 @@
+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",
+  "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 verified identity", 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, identity: ID },
+      { doc_id: "abcd1234" },
+    );
+    expect(stub.agentRead).toHaveBeenCalledWith(ID);
+    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, identity: ID },
+      { 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, identity: ID },
+      { doc_id: "abcd1234", anchor: "b1-aaaabbbb", where: "after", markdown: "hi", pace: "instant" },
+    );
+    expect(stub.agentInsert).toHaveBeenCalledWith(ID, {
+      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, identity: ID },
+      { doc_id: "abcd1234", from_anchor: "b1-aaaabbbb", to_anchor: "b2-ccccdddd", markdown: "x" },
+    );
+    expect(stub.agentReplace).toHaveBeenCalledWith(ID, {
+      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, identity: ID },
+      { doc_id: "abcd1234", anchor: "b1-aaaabbbb", find: "old", replacement: "new" },
+    );
+    expect(stub.agentSuggest).toHaveBeenCalledWith(ID, {
+      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, identity: ID };
+
+    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(ID, {
+      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(ID, { 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, identity: ID };
+
+    await TOOLS.find((t) => t.name === "join")!.run(deps, {
+      doc_id: "abcd1234",
+      status: "drafting",
+    });
+    expect(stub.agentJoin).toHaveBeenCalledWith(ID, "drafting");
+
+    await TOOLS.find((t) => t.name === "leave")!.run(deps, { doc_id: "abcd1234" });
+    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, identity: ID },
+      { doc_id: "abcd1234", since_cursor: 7, timeout_s: 30 },
+    );
+    expect(stub.agentAwaitEvents).toHaveBeenCalledWith(ID, {
+      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, identity: ID },
+      { doc_id: "abcd1234" },
+    );
+    expect(out).toMatchObject({ error: { code: "invalid_token" } });
+  });
+});
+
+describe("validateNewDocumentMarkdown", () => {
+  it("accepts absent and ordinary markdown", () => {
+    expect(validateNewDocumentMarkdown(undefined)).toBeNull();
+    expect(validateNewDocumentMarkdown("# Hello\n\nWorld")).toBeNull();
+  });
+
+  it("rejects markdown over the 1MB cap", () => {
+    expect(validateNewDocumentMarkdown("a".repeat(1_000_001))).toMatchObject({
+      error: { code: "rate_limited", message: expect.stringContaining("1MB") },
+    });
+    expect(validateNewDocumentMarkdown("a".repeat(1_000_000))).toBeNull();
+  });
+
+  it("rejects content containing a NUL byte as binary", () => {
+    expect(validateNewDocumentMarkdown("text\0more")).toMatchObject({
+      error: { code: "unsupported_markup", message: expect.stringContaining("binary") },
+    });
+  });
+});
+
+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");
+  });
+});
+
+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 /);
+  });
+});
diff --git a/tests/unit/agents/oauth.test.ts b/tests/unit/agents/oauth.test.ts
new file mode 100644
index 00000000..0f39f12d
--- /dev/null
+++ b/tests/unit/agents/oauth.test.ts
@@ -0,0 +1,395 @@
+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";
+
+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:ada@example.com", email: "ada@example.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:ada@example.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("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", {
+        method: "POST",
+        body: new URLSearchParams({ token: "whatever" }).toString(),
+      }),
+      deps(fakeRegistry()),
+    );
+    expect(res?.status).toBe(200);
+  });
+});
diff --git a/tests/unit/agents/worker-routes.test.ts b/tests/unit/agents/worker-routes.test.ts
new file mode 100644
index 00000000..240c3327
--- /dev/null
+++ b/tests/unit/agents/worker-routes.test.ts
@@ -0,0 +1,328 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+  handleRawMarkdown,
+  handleMcpHelp,
+  redirectHost,
+  redirectLegacyDocPath,
+} from "../../../workers/routes";
+import * as routesModule 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(res!.headers.get("X-Content-Type-Options")).toBe("nosniff");
+    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();
+  });
+});
+
+describe("redirectLegacyDocPath", () => {
+  it("permanently redirects /docs/:id to /:id", () => {
+    const res = redirectLegacyDocPath(new Request("https://vapor.fyi/docs/abcd1234"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("/abcd1234");
+  });
+
+  it("permanently redirects /docs/:id.md to /:id.md", () => {
+    const res = redirectLegacyDocPath(new Request("https://vapor.fyi/docs/abcd1234.md"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("/abcd1234.md");
+  });
+
+  it("preserves the query string", () => {
+    const res = redirectLegacyDocPath(
+      new Request("https://vapor.fyi/docs/abcd1234?ref=slack&x=1"),
+    );
+
+    expect(res!.headers.get("Location")).toBe("/abcd1234?ref=slack&x=1");
+  });
+
+  it("preserves the query string on the .md form", () => {
+    const res = redirectLegacyDocPath(new Request("https://vapor.fyi/docs/abcd1234.md?raw=1"));
+
+    expect(res!.headers.get("Location")).toBe("/abcd1234.md?raw=1");
+  });
+
+  it("returns null for an id that isn't a valid document id", () => {
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docs/nope"))).toBeNull();
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docs/ABCD1234"))).toBeNull();
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docs/nope.md"))).toBeNull();
+  });
+
+  it("returns null for /docs and for deeper paths", () => {
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docs"))).toBeNull();
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docs/"))).toBeNull();
+    expect(
+      redirectLegacyDocPath(new Request("https://vapor.fyi/docs/abcd1234/edit")),
+    ).toBeNull();
+  });
+
+  it("returns null for unrelated paths", () => {
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/abcd1234"))).toBeNull();
+    expect(redirectLegacyDocPath(new Request("https://vapor.fyi/docsomething"))).toBeNull();
+  });
+
+  it("returns null for non-GET requests", () => {
+    const res = redirectLegacyDocPath(
+      new Request("https://vapor.fyi/docs/abcd1234", { method: "POST" }),
+    );
+
+    expect(res).toBeNull();
+  });
+});
+
+describe("redirectHost", () => {
+  it("redirects vpr.fyi with path and query to https://vapor.fyi", () => {
+    const res = redirectHost(new Request("https://vpr.fyi/abc?x=1"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("https://vapor.fyi/abc?x=1");
+  });
+
+  it("redirects www.vpr.fyi to https://vapor.fyi", () => {
+    const res = redirectHost(new Request("https://www.vpr.fyi/path?q=2"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("https://vapor.fyi/path?q=2");
+  });
+
+  it("redirects vaporware.fyi to https://vapor.fyi", () => {
+    const res = redirectHost(new Request("https://vaporware.fyi/test"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("https://vapor.fyi/test");
+  });
+
+  it("redirects www.vaporware.fyi to https://vapor.fyi", () => {
+    const res = redirectHost(new Request("https://www.vaporware.fyi/doc?id=123"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("https://vapor.fyi/doc?id=123");
+  });
+
+  it("redirects www.vapor.fyi to https://vapor.fyi", () => {
+    const res = redirectHost(new Request("https://www.vapor.fyi/"));
+
+    expect(res).not.toBeNull();
+    expect(res!.status).toBe(301);
+    expect(res!.headers.get("Location")).toBe("https://vapor.fyi/");
+  });
+
+  it("returns null for vapor.fyi (primary domain)", () => {
+    const res = redirectHost(new Request("https://vapor.fyi/abc"));
+
+    expect(res).toBeNull();
+  });
+
+  it("returns null for localhost", () => {
+    const res = redirectHost(new Request("https://localhost:3000/abc"));
+
+    expect(res).toBeNull();
+  });
+
+  it("returns null for workers.dev subdomain", () => {
+    const res = redirectHost(new Request("https://vapor.arfct.workers.dev/abc"));
+
+    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: "Ada@Example.com",
+        name: "Ada",
+        picture: "https://p/x.png",
+      })),
+      upsertProfile: vi.fn(async () => ({
+        profile: { displayName: "Ada", agentSlug: null },
+      })),
+      getProfile: vi.fn(async () => ({
+        profile: { displayName: "Ada", agentSlug: "ada" },
+      })),
+      ...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:ada@example.com",
+      expect.objectContaining({ displayName: "Ada" }),
+    );
+  });
+
+  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:ada@example.com");
+    expect(body.agentSlug).toBe("ada");
+  });
+
+  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/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/format-toolbar.test.tsx b/tests/unit/components/format-toolbar.test.tsx
new file mode 100644
index 00000000..5ebd1c78
--- /dev/null
+++ b/tests/unit/components/format-toolbar.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from "vitest";
+import { createElement } from "react";
+import { renderWithDocument } from "../../helpers/document-context";
+import FormatToolbar from "~/components/FormatToolbar";
+
+describe("FormatToolbar", () => {
+  it("renders nothing without an editor", () => {
+    const { container } = renderWithDocument(createElement(FormatToolbar), {
+      context: { editorInstance: null },
+    });
+    expect(container.textContent).toBe("");
+  });
+
+  it("renders the three menu triggers when an editor exists", () => {
+    const fakeEditor = {
+      on: () => {},
+      off: () => {},
+      isFocused: false,
+      isActive: () => false,
+      state: { selection: { empty: true } },
+      chain: () => ({ focus: () => ({ run: () => {} }) }),
+    };
+    const { getByLabelText } = renderWithDocument(createElement(FormatToolbar), {
+      context: { editorInstance: fakeEditor as never },
+    });
+    expect(getByLabelText("Formatting")).toBeTruthy();
+    expect(getByLabelText("Lists")).toBeTruthy();
+    expect(getByLabelText("Insert")).toBeTruthy();
+  });
+});
diff --git a/tests/unit/components/header-menu.test.tsx b/tests/unit/components/header-menu.test.tsx
new file mode 100644
index 00000000..cc744131
--- /dev/null
+++ b/tests/unit/components/header-menu.test.tsx
@@ -0,0 +1,62 @@
+// @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 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.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..44e16d83 100644
--- a/tests/unit/components/mobile-panel.test.tsx
+++ b/tests/unit/components/mobile-panel.test.tsx
@@ -18,32 +18,26 @@ describe("MobilePanel", () => {
   });
 
   it("clicking a tab shows corresponding content, clicking again collapses", () => {
+    const thread = {
+      id: "t1",
+      commentText: "A comment",
+      author: { name: "Alice", color: "#000", colorLight: "#ccc" },
+      createdAt: Date.now(),
+      resolved: false,
+      replies: [],
+    };
     const { getByText, queryByText } = renderWithDocument(
       createElement(MobilePanel, { className: "lg:hidden" }),
-      { context: { mode: "suggest" } },
+      { context: { threads: [thread] } },
     );
 
-    // Editing tab starts active, should show ModeToggle content
-    expect(queryByText("Suggest changes")).toBeTruthy();
-
     // Click Comments tab
     fireEvent.click(getByText("Comments"));
-    expect(queryByText("Comments (0)")).toBeTruthy();
+    expect(queryByText("A comment")).toBeTruthy();
 
     // Click Comments tab again to collapse
     fireEvent.click(getByText("Comments"));
-    expect(queryByText("Comments (0)")).toBeFalsy();
-  });
-
-  it("editing tab renders ModeToggle and SuggestionActions", () => {
-    const { getByText, getByLabelText } = renderWithDocument(
-      createElement(MobilePanel, { className: "lg:hidden" }),
-    );
-
-    // 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(queryByText("A comment")).toBeFalsy();
   });
 
   it("comments tab renders CommentInput and ThreadList", () => {
@@ -54,6 +48,5 @@ describe("MobilePanel", () => {
 
     fireEvent.click(getByText("Comments"));
     expect(queryByText("Comment")).toBeTruthy();
-    expect(queryByText("No comments yet")).toBeTruthy();
   });
 });
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..6020c130 100644
--- a/tests/unit/components/remaining.test.tsx
+++ b/tests/unit/components/remaining.test.tsx
@@ -1,44 +1,11 @@
 // @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 CleanViewToggle from "~/components/CleanViewToggle";
-import SuggestionActions from "~/components/SuggestionActions";
 import ShareButton from "~/components/ShareButton";
 import ConnectionStatus from "~/components/ConnectionStatus";
 import Preview from "~/components/Preview";
 
-describe("CleanViewToggle", () => {
-  it("renders checkbox reflecting cleanView state", () => {
-    const { getByText } = renderWithDocument(createElement(CleanViewToggle), {
-      context: { cleanView: true },
-    });
-    expect(getByText("Show editing markup")).toBeTruthy();
-  });
-
-  it("toggle calls toggleCleanView", () => {
-    const { contextValue, getByRole } = renderWithDocument(
-      createElement(CleanViewToggle),
-    );
-    fireEvent.click(getByRole("checkbox"));
-    expect(contextValue.toggleCleanView).toHaveBeenCalledOnce();
-  });
-});
-
-describe("SuggestionActions", () => {
-  it("renders action buttons in suggest mode", () => {
-    const { getByText } = renderWithDocument(
-      createElement(SuggestionActions),
-      { context: { mode: "suggest" } },
-    );
-    expect(getByText("Accept")).toBeTruthy();
-    expect(getByText("Reject")).toBeTruthy();
-    expect(getByText("Accept all")).toBeTruthy();
-    expect(getByText("Reject all")).toBeTruthy();
-  });
-});
-
 describe("ShareButton", () => {
   it("renders share trigger button", () => {
     const { getByLabelText } = renderWithDocument(createElement(ShareButton));
@@ -54,10 +21,11 @@ describe("ConnectionStatus", () => {
 });
 
 describe("Preview", () => {
-  it("renders markdown as HTML", () => {
-    const { container } = renderWithDocument(createElement(Preview), {
-      context: { markdown: "Hello world" },
+  it("shows the document's markdown source", () => {
+    const { container, getByText } = renderWithDocument(createElement(Preview), {
+      context: { markdown: "# Hello world" },
     });
-    expect(container.querySelector(".preview")).toBeTruthy();
+    expect(container.querySelector("pre")).toBeTruthy();
+    expect(getByText("# Hello world")).toBeTruthy();
   });
 });
diff --git a/tests/unit/components/thread-list.test.tsx b/tests/unit/components/thread-list.test.tsx
index 3da54013..0c860145 100644
--- a/tests/unit/components/thread-list.test.tsx
+++ b/tests/unit/components/thread-list.test.tsx
@@ -17,10 +17,9 @@ const makeThread = (overrides = {}) => ({
 });
 
 describe("ThreadList", () => {
-  it("shows thread count and 'No comments yet' when empty", () => {
-    const { getByText } = renderWithDocument(createElement(ThreadList));
-    expect(getByText("Comments (0)")).toBeTruthy();
-    expect(getByText("No comments yet")).toBeTruthy();
+  it("renders nothing when there are no threads", () => {
+    const { container } = renderWithDocument(createElement(ThreadList));
+    expect(container.textContent).toBe("");
   });
 
   it("renders ThreadPanel for each open thread with separator borders", () => {
@@ -31,7 +30,6 @@ describe("ThreadList", () => {
     const { getByText, container } = renderWithDocument(createElement(ThreadList), {
       context: { threads },
     });
-    expect(getByText("Comments (2)")).toBeTruthy();
     expect(getByText("First")).toBeTruthy();
     expect(getByText("Second")).toBeTruthy();
 
@@ -50,20 +48,10 @@ describe("ThreadList", () => {
       { context: { threads } },
     );
 
-    expect(getByText("Comments (1)")).toBeTruthy();
     expect(queryByText("Resolved thread")).toBeFalsy();
     expect(getByText("Show resolved (1)")).toBeTruthy();
 
     fireEvent.click(getByText("Show resolved (1)"));
     expect(queryByText("Resolved thread")).toBeTruthy();
   });
-
-  it("new comment button calls openCommentInput", () => {
-    const { contextValue, getByLabelText } = renderWithDocument(
-      createElement(ThreadList),
-    );
-
-    fireEvent.click(getByLabelText("New comment"));
-    expect(contextValue.openCommentInput).toHaveBeenCalledOnce();
-  });
 });
diff --git a/tests/unit/components/thread-panel.test.tsx b/tests/unit/components/thread-panel.test.tsx
index 09f32577..8df77f0b 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", () => {
@@ -76,18 +73,17 @@ 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 vertical border line and no color dots", () => {
+  it("renders replies with author header and text", () => {
     const props = {
       ...defaultProps(),
       thread: makeThread({
@@ -101,38 +97,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 resolveBtn = getByLabelText("Resolve");
+    const moreBtn = getByLabelText("More actions");
 
-    const replyBtn = getByText("Reply");
-    const resolveBtn = getByText("Resolve");
-    const deleteBtn = getByText("Delete");
+    expect(resolveBtn.querySelector(".material-symbols-outlined")).toBeTruthy();
+    expect(moreBtn.querySelector(".material-symbols-outlined")).toBeTruthy();
+  });
 
-    // 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");
+  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 +131,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 +157,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();
   });
 });
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);
+    }
+  });
+});
diff --git a/tests/unit/lib/anon-identity.test.ts b/tests/unit/lib/anon-identity.test.ts
new file mode 100644
index 00000000..c8e158b1
--- /dev/null
+++ b/tests/unit/lib/anon-identity.test.ts
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach } from "vitest";
+import { getAnonIdentity, retireAnonId, formerAnonId } from "~/lib/anon-identity";
+import { ANON_ANIMALS, ANON_ADJECTIVES } 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(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();
+    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();
+  });
+});
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/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);
+  });
+});
diff --git a/tests/unit/lib/critic-constants.test.ts b/tests/unit/lib/critic-constants.test.ts
deleted file mode 100644
index b100805b..00000000
--- a/tests/unit/lib/critic-constants.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { DELIMITERS, DELIMITER_LENGTH } from "~/lib/critic-constants";
-
-describe("DELIMITERS", () => {
-  it("has all five CriticMarkup types", () => {
-    expect(Object.keys(DELIMITERS)).toEqual([
-      "addition",
-      "deletion",
-      "substitution",
-      "comment",
-      "highlight",
-    ]);
-  });
-
-  it("all open delimiters match DELIMITER_LENGTH", () => {
-    for (const [, delim] of Object.entries(DELIMITERS)) {
-      expect(delim.open.length).toBe(DELIMITER_LENGTH);
-    }
-  });
-
-  it("all close delimiters match DELIMITER_LENGTH", () => {
-    for (const [, delim] of Object.entries(DELIMITERS)) {
-      expect(delim.close.length).toBe(DELIMITER_LENGTH);
-    }
-  });
-
-  it("substitution has a separator", () => {
-    expect(DELIMITERS.substitution.separator).toBe("~>");
-  });
-});
diff --git a/tests/unit/lib/critic-markup.test.ts b/tests/unit/lib/critic-markup.test.ts
deleted file mode 100644
index 98284ba4..00000000
--- a/tests/unit/lib/critic-markup.test.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
-  parseCriticRanges,
-  acceptRange,
-  rejectRange,
-  acceptAll,
-  rejectAll,
-  resolvedContent,
-  type CriticRange,
-} from "~/lib/critic-markup";
-
-describe("parseCriticRanges", () => {
-  it("parses addition", () => {
-    const ranges = parseCriticRanges("hello {++world++}");
-    expect(ranges).toEqual([
-      { type: "addition", start: 6, end: 17, content: { addition: "world" } },
-    ]);
-  });
-
-  it("parses deletion", () => {
-    const ranges = parseCriticRanges("{--removed--} text");
-    expect(ranges).toEqual([
-      {
-        type: "deletion",
-        start: 0,
-        end: 13,
-        content: { deletion: "removed" },
-      },
-    ]);
-  });
-
-  it("parses substitution", () => {
-    const ranges = parseCriticRanges("{~~old~>new~~}");
-    expect(ranges).toEqual([
-      {
-        type: "substitution",
-        start: 0,
-        end: 14,
-        content: { deletion: "old", addition: "new" },
-      },
-    ]);
-  });
-
-  it("parses comment", () => {
-    const ranges = parseCriticRanges("{>>note<<}");
-    expect(ranges).toEqual([
-      { type: "comment", start: 0, end: 10, content: { comment: "note" } },
-    ]);
-  });
-
-  it("parses highlight", () => {
-    const ranges = parseCriticRanges("{==important==}");
-    expect(ranges).toEqual([
-      {
-        type: "highlight",
-        start: 0,
-        end: 15,
-        content: { highlight: "important" },
-      },
-    ]);
-  });
-
-  it("parses multiple ranges", () => {
-    const text = "hello {++added++} and {--removed--}";
-    const ranges = parseCriticRanges(text);
-    expect(ranges).toHaveLength(2);
-    expect(ranges[0].type).toBe("addition");
-    expect(ranges[1].type).toBe("deletion");
-  });
-
-  it("returns correct positions", () => {
-    const text = "ab{++cd++}ef";
-    const ranges = parseCriticRanges(text);
-    expect(ranges[0].start).toBe(2);
-    expect(ranges[0].end).toBe(10);
-  });
-
-  it("splits paired highlight + comment into two ranges", () => {
-    const ranges = parseCriticRanges("{==goodgood==}{>>comment<<}");
-    expect(ranges).toHaveLength(2);
-    expect(ranges[0]).toEqual({
-      type: "highlight",
-      start: 0,
-      end: 14,
-      content: { highlight: "goodgood" },
-    });
-    expect(ranges[1]).toEqual({
-      type: "comment",
-      start: 14,
-      end: 27,
-      content: { comment: "comment" },
-    });
-  });
-
-  it("does not duplicate highlight when paired with comment", () => {
-    const ranges = parseCriticRanges("text {==hl==}{>>cm<<} end");
-    const highlights = ranges.filter((r) => r.type === "highlight");
-    expect(highlights).toHaveLength(1);
-  });
-});
-
-describe("acceptRange", () => {
-  it("accepts addition: keeps content", () => {
-    const text = "hello {++world++}";
-    const range: CriticRange = {
-      type: "addition",
-      start: 6,
-      end: 17,
-      content: { addition: "world" },
-    };
-    expect(acceptRange(text, range)).toBe("hello world");
-  });
-
-  it("accepts deletion: removes content", () => {
-    const text = "hello {--world--} end";
-    const range: CriticRange = {
-      type: "deletion",
-      start: 6,
-      end: 17,
-      content: { deletion: "world" },
-    };
-    expect(acceptRange(text, range)).toBe("hello  end");
-  });
-
-  it("accepts substitution: keeps new text", () => {
-    const text = "say {~~hello~>goodbye~~} now";
-    const range: CriticRange = {
-      type: "substitution",
-      start: 4,
-      end: 24,
-      content: { deletion: "hello", addition: "goodbye" },
-    };
-    expect(acceptRange(text, range)).toBe("say goodbye now");
-  });
-
-  it("accepts comment: removes it", () => {
-    const text = "text{>>note<<} more";
-    const range: CriticRange = {
-      type: "comment",
-      start: 4,
-      end: 14,
-      content: { comment: "note" },
-    };
-    expect(acceptRange(text, range)).toBe("text more");
-  });
-
-  it("accepts highlight: keeps content", () => {
-    const text = "see {==this==} part";
-    const range: CriticRange = {
-      type: "highlight",
-      start: 4,
-      end: 14,
-      content: { highlight: "this" },
-    };
-    expect(acceptRange(text, range)).toBe("see this part");
-  });
-});
-
-describe("rejectRange", () => {
-  it("rejects addition: removes content", () => {
-    const text = "hello {++world++}";
-    const range: CriticRange = {
-      type: "addition",
-      start: 6,
-      end: 17,
-      content: { addition: "world" },
-    };
-    expect(rejectRange(text, range)).toBe("hello ");
-  });
-
-  it("rejects deletion: keeps content", () => {
-    const text = "hello {--world--} end";
-    const range: CriticRange = {
-      type: "deletion",
-      start: 6,
-      end: 17,
-      content: { deletion: "world" },
-    };
-    expect(rejectRange(text, range)).toBe("hello world end");
-  });
-
-  it("rejects substitution: keeps old text", () => {
-    const text = "say {~~hello~>goodbye~~} now";
-    const range: CriticRange = {
-      type: "substitution",
-      start: 4,
-      end: 24,
-      content: { deletion: "hello", addition: "goodbye" },
-    };
-    expect(rejectRange(text, range)).toBe("say hello now");
-  });
-
-  it("rejects comment: removes it", () => {
-    const text = "text{>>note<<} more";
-    const range: CriticRange = {
-      type: "comment",
-      start: 4,
-      end: 14,
-      content: { comment: "note" },
-    };
-    expect(rejectRange(text, range)).toBe("text more");
-  });
-
-  it("rejects highlight: keeps content", () => {
-    const text = "see {==this==} part";
-    const range: CriticRange = {
-      type: "highlight",
-      start: 4,
-      end: 14,
-      content: { highlight: "this" },
-    };
-    expect(rejectRange(text, range)).toBe("see this part");
-  });
-});
-
-describe("resolvedContent", () => {
-  it("accept addition returns content", () => {
-    const range: CriticRange = {
-      type: "addition",
-      start: 0,
-      end: 11,
-      content: { addition: "world" },
-    };
-    expect(resolvedContent(range, true)).toBe("world");
-  });
-
-  it("accept deletion returns empty", () => {
-    const range: CriticRange = {
-      type: "deletion",
-      start: 0,
-      end: 13,
-      content: { deletion: "removed" },
-    };
-    expect(resolvedContent(range, true)).toBe("");
-  });
-
-  it("accept substitution returns new text", () => {
-    const range: CriticRange = {
-      type: "substitution",
-      start: 0,
-      end: 14,
-      content: { deletion: "old", addition: "new" },
-    };
-    expect(resolvedContent(range, true)).toBe("new");
-  });
-
-  it("reject addition returns empty", () => {
-    const range: CriticRange = {
-      type: "addition",
-      start: 0,
-      end: 11,
-      content: { addition: "world" },
-    };
-    expect(resolvedContent(range, false)).toBe("");
-  });
-
-  it("reject deletion returns content", () => {
-    const range: CriticRange = {
-      type: "deletion",
-      start: 0,
-      end: 13,
-      content: { deletion: "removed" },
-    };
-    expect(resolvedContent(range, false)).toBe("removed");
-  });
-
-  it("reject substitution returns old text", () => {
-    const range: CriticRange = {
-      type: "substitution",
-      start: 0,
-      end: 14,
-      content: { deletion: "old", addition: "new" },
-    };
-    expect(resolvedContent(range, false)).toBe("old");
-  });
-});
-
-describe("acceptAll", () => {
-  it("accepts all ranges in a mixed document", () => {
-    const text = "hello {++new++} and {--old--} with {~~bad~>good~~}";
-    expect(acceptAll(text)).toBe("hello new and  with good");
-  });
-
-  it("handles document with no CriticMarkup", () => {
-    expect(acceptAll("plain text")).toBe("plain text");
-  });
-
-  it("handles adjacent suggestions", () => {
-    const text = "{++a++}{--b--}";
-    expect(acceptAll(text)).toBe("a");
-  });
-
-  it("accepts paired highlight + comment (strips both)", () => {
-    const text = "text {==goodgood==}{>>comment<<} end";
-    expect(acceptAll(text)).toBe("text goodgood end");
-  });
-});
-
-describe("rejectAll", () => {
-  it("rejects all ranges in a mixed document", () => {
-    const text = "hello {++new++} and {--old--} with {~~bad~>good~~}";
-    expect(rejectAll(text)).toBe("hello  and old with bad");
-  });
-
-  it("handles document with no CriticMarkup", () => {
-    expect(rejectAll("plain text")).toBe("plain text");
-  });
-
-  it("handles adjacent suggestions", () => {
-    const text = "{++a++}{--b--}";
-    expect(rejectAll(text)).toBe("b");
-  });
-
-  it("rejects paired highlight + comment (strips both)", () => {
-    const text = "text {==goodgood==}{>>comment<<} end";
-    expect(rejectAll(text)).toBe("text goodgood end");
-  });
-});
diff --git a/tests/unit/lib/critic-parser.test.ts b/tests/unit/lib/critic-parser.test.ts
deleted file mode 100644
index 53ead68f..00000000
--- a/tests/unit/lib/critic-parser.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { parseCriticMarkupToContent } from "~/lib/critic-parser";
-
-describe("parseCriticMarkupToContent", () => {
-  it("parses addition", () => {
-    const result = parseCriticMarkupToContent("hello {++world++}");
-    expect(result.cleanText).toBe("hello world");
-    expect(result.marks).toEqual([
-      { from: 6, to: 11, type: "criticAddition" },
-    ]);
-  });
-
-  it("parses deletion", () => {
-    const result = parseCriticMarkupToContent("{--removed--} text");
-    expect(result.cleanText).toBe("removed text");
-    expect(result.marks).toEqual([
-      { from: 0, to: 7, type: "criticDeletion" },
-    ]);
-  });
-
-  it("parses comment", () => {
-    const result = parseCriticMarkupToContent("{>>a note<<}");
-    expect(result.cleanText).toBe("a note");
-    expect(result.marks).toEqual([
-      { from: 0, to: 6, type: "criticComment" },
-    ]);
-  });
-
-  it("parses highlight", () => {
-    const result = parseCriticMarkupToContent("{==important==}");
-    expect(result.cleanText).toBe("important");
-    expect(result.marks).toEqual([
-      { from: 0, to: 9, type: "criticHighlight" },
-    ]);
-  });
-
-  it("parses multiple ranges", () => {
-    const result = parseCriticMarkupToContent("hello {++new++} and {--old--}");
-    expect(result.cleanText).toBe("hello new and old");
-    expect(result.marks).toHaveLength(2);
-    expect(result.marks[0].type).toBe("criticAddition");
-    expect(result.marks[1].type).toBe("criticDeletion");
-  });
-
-  it("handles plain text without CriticMarkup", () => {
-    const result = parseCriticMarkupToContent("plain text");
-    expect(result.cleanText).toBe("plain text");
-    expect(result.marks).toEqual([]);
-  });
-
-  it("throws on substitution", () => {
-    expect(() =>
-      parseCriticMarkupToContent("{~~old~>new~~}"),
-    ).toThrow("Unsupported CriticMarkup: substitution");
-  });
-
-  it("preserves positions correctly with adjacent marks", () => {
-    const result = parseCriticMarkupToContent("{--old--}{++new++}");
-    expect(result.cleanText).toBe("oldnew");
-    expect(result.marks).toEqual([
-      { from: 0, to: 3, type: "criticDeletion" },
-      { from: 3, to: 6, type: "criticAddition" },
-    ]);
-  });
-
-  it("parses paired highlight + comment", () => {
-    const result = parseCriticMarkupToContent(
-      "{==goodgood==}{>>Should we use a stronger word here?<<}",
-    );
-    expect(result.cleanText).toBe(
-      "goodgoodShould we use a stronger word here?",
-    );
-    expect(result.marks).toEqual([
-      { from: 0, to: 8, type: "criticHighlight" },
-      { from: 8, to: 43, type: "criticComment" },
-    ]);
-  });
-
-  it("parses paired highlight + comment with surrounding text", () => {
-    const result = parseCriticMarkupToContent(
-      "text {==goodgood==}{>>comment<<} more",
-    );
-    expect(result.cleanText).toBe("text goodgoodcomment more");
-    expect(result.marks).toEqual([
-      { from: 5, to: 13, type: "criticHighlight" },
-      { from: 13, to: 20, type: "criticComment" },
-    ]);
-  });
-
-  it("does not duplicate highlight when paired with comment", () => {
-    const result = parseCriticMarkupToContent(
-      "{==highlighted==}{>>note<<}",
-    );
-    // Should be exactly 2 marks (highlight + comment), not 3
-    expect(result.marks).toHaveLength(2);
-    expect(result.marks[0].type).toBe("criticHighlight");
-    expect(result.marks[1].type).toBe("criticComment");
-  });
-});
diff --git a/tests/unit/lib/critic-serializer.test.ts b/tests/unit/lib/critic-serializer.test.ts
deleted file mode 100644
index f1e23e44..00000000
--- a/tests/unit/lib/critic-serializer.test.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { Schema } from "@tiptap/pm/model";
-import { serializeWithCriticMarkup } from "~/lib/critic-serializer";
-
-const schema = new Schema({
-  nodes: {
-    doc: { content: "paragraph+" },
-    paragraph: { content: "text*", toDOM: () => ["p", 0] as const },
-    text: { inline: true },
-  },
-  marks: {
-    criticAddition: {
-      toDOM: () => ["span", { class: "cm-addition" }, 0] as const,
-    },
-    criticDeletion: {
-      toDOM: () => ["span", { class: "cm-deletion" }, 0] as const,
-    },
-    criticComment: {
-      toDOM: () => ["span", { class: "cm-comment" }, 0] as const,
-    },
-    criticHighlight: {
-      attrs: { threadId: { default: null } },
-      toDOM: () => ["span", { class: "cm-highlight" }, 0] as const,
-    },
-  },
-});
-
-function makeDoc(...paragraphs: { text: string; mark?: string }[][]) {
-  const pNodes = paragraphs.map((nodes) => {
-    const children = nodes.map(({ text, mark }) => {
-      if (mark) {
-        return schema.text(text, [schema.marks[mark].create()]);
-      }
-      return schema.text(text);
-    });
-    return schema.node("paragraph", null, children.length > 0 ? children : undefined);
-  });
-  return schema.node("doc", null, pNodes);
-}
-
-describe("serializeWithCriticMarkup", () => {
-  it("serializes plain text", () => {
-    const doc = makeDoc([{ text: "hello world" }]);
-    expect(serializeWithCriticMarkup(doc)).toBe("hello world");
-  });
-
-  it("serializes addition mark", () => {
-    const doc = makeDoc([
-      { text: "hello " },
-      { text: "world", mark: "criticAddition" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe("hello {++world++}");
-  });
-
-  it("serializes deletion mark", () => {
-    const doc = makeDoc([
-      { text: "hello " },
-      { text: "world", mark: "criticDeletion" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe("hello {--world--}");
-  });
-
-  it("serializes comment mark", () => {
-    const doc = makeDoc([
-      { text: "text" },
-      { text: "a note", mark: "criticComment" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe("text{>>a note<<}");
-  });
-
-  it("serializes highlight mark", () => {
-    const doc = makeDoc([
-      { text: "see " },
-      { text: "this", mark: "criticHighlight" },
-      { text: " part" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe("see {==this==} part");
-  });
-
-  it("serializes multiple paragraphs", () => {
-    const doc = makeDoc(
-      [{ text: "first" }],
-      [{ text: "second" }],
-    );
-    expect(serializeWithCriticMarkup(doc)).toBe("first\nsecond");
-  });
-
-  it("serializes adjacent addition and deletion", () => {
-    const doc = makeDoc([
-      { text: "old", mark: "criticDeletion" },
-      { text: "new", mark: "criticAddition" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe("{--old--}{++new++}");
-  });
-
-  it("serializes mixed marks with plain text", () => {
-    const doc = makeDoc([
-      { text: "hello " },
-      { text: "added", mark: "criticAddition" },
-      { text: " middle " },
-      { text: "removed", mark: "criticDeletion" },
-      { text: " end" },
-    ]);
-    expect(serializeWithCriticMarkup(doc)).toBe(
-      "hello {++added++} middle {--removed--} end",
-    );
-  });
-
-  it("serializes empty paragraph", () => {
-    const doc = makeDoc([], [{ text: "text" }]);
-    expect(serializeWithCriticMarkup(doc)).toBe("\ntext");
-  });
-});
diff --git a/tests/unit/lib/markdown-decorations.test.ts b/tests/unit/lib/markdown-decorations.test.ts
deleted file mode 100644
index 178c94aa..00000000
--- a/tests/unit/lib/markdown-decorations.test.ts
+++ /dev/null
@@ -1,463 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
-  MARKDOWN_PATTERNS,
-  findDecorations,
-  findCodeBlockDecorations,
-  CODE_FENCE_REGEX,
-  highlightLine,
-  getLanguageOptions,
-  type MarkdownPattern,
-} from "~/lib/markdown-decorations";
-
-function getPattern(name: string): MarkdownPattern {
-  const p = MARKDOWN_PATTERNS.find((p) => p.name === name);
-  if (!p) throw new Error(`Pattern ${name} not found`);
-  return p;
-}
-
-function matchPositions(text: string, pattern: MarkdownPattern) {
-  return findDecorations(text, 0, pattern).map((d) => ({
-    from: d.from,
-    to: d.to,
-    // Decoration.inline stores the attrs object directly in spec
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    class: (d as any).type.attrs?.class,
-  }));
-}
-
-describe("MARKDOWN_PATTERNS", () => {
-  it("exports all expected pattern names", () => {
-    const names = MARKDOWN_PATTERNS.map((p) => p.name);
-    expect(names).toContain("bold");
-    expect(names).toContain("italic");
-    expect(names).toContain("code");
-    expect(names).toContain("strikethrough");
-    expect(names).toContain("heading");
-    expect(names).toContain("link");
-    expect(names).toContain("blockquote");
-    expect(names).toContain("list");
-    expect(names).toContain("hr");
-  });
-});
-
-describe("findDecorations", () => {
-  describe("bold", () => {
-    const pattern = getPattern("bold");
-
-    it("decorates **bold** text", () => {
-      const decos = matchPositions("hello **world** end", pattern);
-      expect(decos).toEqual([
-        { from: 6, to: 8, class: "md-delimiter" },
-        { from: 8, to: 13, class: "md-bold" },
-        { from: 13, to: 15, class: "md-delimiter" },
-      ]);
-    });
-
-    it("handles multiple bold spans", () => {
-      const decos = matchPositions("**a** and **b**", pattern);
-      expect(decos).toHaveLength(6);
-    });
-
-    it("returns nothing for unmatched text", () => {
-      const decos = matchPositions("no bold here", pattern);
-      expect(decos).toHaveLength(0);
-    });
-  });
-
-  describe("italic", () => {
-    const pattern = getPattern("italic");
-
-    it("decorates *italic* text", () => {
-      const decos = matchPositions("hello *world* end", pattern);
-      expect(decos).toEqual([
-        { from: 6, to: 7, class: "md-delimiter" },
-        { from: 7, to: 12, class: "md-italic" },
-        { from: 12, to: 13, class: "md-delimiter" },
-      ]);
-    });
-  });
-
-  describe("code", () => {
-    const pattern = getPattern("code");
-
-    it("decorates `code` text", () => {
-      const decos = matchPositions("use `npm install` here", pattern);
-      expect(decos).toEqual([
-        { from: 4, to: 5, class: "md-delimiter" },
-        { from: 5, to: 16, class: "md-code" },
-        { from: 16, to: 17, class: "md-delimiter" },
-      ]);
-    });
-  });
-
-  describe("strikethrough", () => {
-    const pattern = getPattern("strikethrough");
-
-    it("decorates ~~strikethrough~~ text", () => {
-      const decos = matchPositions("~~removed~~ text", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-delimiter" },
-        { from: 2, to: 9, class: "md-strikethrough" },
-        { from: 9, to: 11, class: "md-delimiter" },
-      ]);
-    });
-  });
-
-  describe("heading", () => {
-    const pattern = getPattern("heading");
-
-    it("decorates # heading with delimiter and content", () => {
-      const decos = matchPositions("# Hello", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-heading-delimiter" },
-        { from: 2, to: 7, class: "md-heading md-heading-1" },
-      ]);
-    });
-
-    it("decorates ## level 2 heading", () => {
-      const decos = matchPositions("## Hello", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 3, class: "md-heading-delimiter" },
-        { from: 3, to: 8, class: "md-heading md-heading-2" },
-      ]);
-    });
-
-    it("decorates ### level 3 heading", () => {
-      const decos = matchPositions("### Sub", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 4, class: "md-heading-delimiter" },
-        { from: 4, to: 7, class: "md-heading md-heading-3" },
-      ]);
-    });
-
-    it("decorates ###### level 6 heading", () => {
-      const decos = matchPositions("###### Deep", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 7, class: "md-heading-delimiter" },
-        { from: 7, to: 11, class: "md-heading md-heading-6" },
-      ]);
-    });
-  });
-
-  describe("link", () => {
-    const pattern = getPattern("link");
-
-    it("decorates [text](url) with 5 parts", () => {
-      const decos = matchPositions("[click here](https://example.com)", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 1, class: "md-delimiter" },        // [
-        { from: 1, to: 11, class: "md-link-text" },        // click here
-        { from: 11, to: 13, class: "md-delimiter" },       // ](
-        { from: 13, to: 32, class: "md-link-url" },        // https://example.com
-        { from: 32, to: 33, class: "md-delimiter" },       // )
-      ]);
-    });
-
-    it("handles link in middle of text", () => {
-      const decos = matchPositions("see [docs](http://x.co) here", pattern);
-      expect(decos).toHaveLength(5);
-      expect(decos[0]).toEqual({ from: 4, to: 5, class: "md-delimiter" });
-      expect(decos[1]).toEqual({ from: 5, to: 9, class: "md-link-text" });
-    });
-
-    it("handles multiple links", () => {
-      const decos = matchPositions("[a](b) [c](d)", pattern);
-      expect(decos).toHaveLength(10);
-    });
-  });
-
-  describe("blockquote", () => {
-    const pattern = getPattern("blockquote");
-
-    it("decorates > prefix", () => {
-      const decos = matchPositions("> quote text", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-delimiter" },
-      ]);
-    });
-
-    it("does not match > without space", () => {
-      const decos = matchPositions(">nospace", pattern);
-      expect(decos).toHaveLength(0);
-    });
-  });
-
-  describe("list", () => {
-    const pattern = getPattern("list");
-
-    it("decorates - bullet", () => {
-      const decos = matchPositions("- item", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-delimiter" },
-      ]);
-    });
-
-    it("decorates * bullet", () => {
-      const decos = matchPositions("* item", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-delimiter" },
-      ]);
-    });
-
-    it("decorates + bullet", () => {
-      const decos = matchPositions("+ item", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 2, class: "md-delimiter" },
-      ]);
-    });
-
-    it("decorates 1. numbered list", () => {
-      const decos = matchPositions("1. first", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 3, class: "md-delimiter" },
-      ]);
-    });
-
-    it("decorates indented bullet", () => {
-      const decos = matchPositions("  - nested", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 4, class: "md-delimiter" },
-      ]);
-    });
-  });
-
-  describe("hr", () => {
-    const pattern = getPattern("hr");
-
-    it("decorates ---", () => {
-      const decos = matchPositions("---", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 3, class: "md-hr" },
-      ]);
-    });
-
-    it("decorates ***", () => {
-      const decos = matchPositions("***", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 3, class: "md-hr" },
-      ]);
-    });
-
-    it("decorates ___", () => {
-      const decos = matchPositions("___", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 3, class: "md-hr" },
-      ]);
-    });
-
-    it("decorates longer rules", () => {
-      const decos = matchPositions("-----", pattern);
-      expect(decos).toEqual([
-        { from: 0, to: 5, class: "md-hr" },
-      ]);
-    });
-
-    it("does not match fewer than 3 characters", () => {
-      const decos = matchPositions("--", pattern);
-      expect(decos).toHaveLength(0);
-    });
-  });
-
-  describe("basePos offset", () => {
-    it("offsets all decoration positions by basePos", () => {
-      const pattern = getPattern("bold");
-      const decos = findDecorations("**hi**", 10, pattern).map((d) => ({
-        from: d.from,
-        to: d.to,
-      }));
-      expect(decos).toEqual([
-        { from: 10, to: 12 },
-        { from: 12, to: 14 },
-        { from: 14, to: 16 },
-      ]);
-    });
-  });
-});
-
-describe("CODE_FENCE_REGEX", () => {
-  it("matches triple backticks", () => {
-    expect(CODE_FENCE_REGEX.test("```")).toBe(true);
-  });
-
-  it("matches triple backticks with language", () => {
-    expect(CODE_FENCE_REGEX.test("```js")).toBe(true);
-  });
-
-  it("matches 4+ backticks", () => {
-    expect(CODE_FENCE_REGEX.test("````")).toBe(true);
-  });
-
-  it("does not match fewer than 3 backticks", () => {
-    expect(CODE_FENCE_REGEX.test("``")).toBe(false);
-  });
-
-  it("does not match backticks mid-line", () => {
-    expect(CODE_FENCE_REGEX.test("some ``` text")).toBe(false);
-  });
-});
-
-describe("findCodeBlockDecorations", () => {
-  // Helper to create mock paragraph nodes matching the shape
-  // findCodeBlockDecorations expects
-  function makeParagraphs(lines: string[]) {
-    let pos = 0;
-    return lines.map((text) => {
-      // nodeSize = 1 (open tag) + text length + 1 (close tag)
-      const nodeSize = text.length + 2;
-      const para = {
-        node: {
-          textContent: text,
-          nodeSize,
-          type: { name: "paragraph" },
-        },
-        pos,
-      };
-      pos += nodeSize;
-      return para;
-    });
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  function decoInfo(d: any) {
-    return {
-      from: d.from,
-      to: d.to,
-      class: d.type?.attrs?.class ?? d.type?.spec?.class,
-    };
-  }
-
-  it("detects a simple fenced code block", () => {
-    const paras = makeParagraphs(["```", "hello", "```"]);
-    const { decorations, codeBlockRanges } = findCodeBlockDecorations(paras);
-
-    expect(codeBlockRanges).toHaveLength(1);
-    // 3 paragraphs: "```" (nodeSize 5) + "hello" (7) + "```" (5) = 17
-    expect(codeBlockRanges[0]).toEqual({ from: 0, to: 17 });
-
-    const classes = decorations.map(decoInfo).map((d) => d.class);
-    expect(classes).toContain("md-code-block md-code-block-open");
-    expect(classes).toContain("md-code-block");
-    expect(classes).toContain("md-code-block md-code-block-close");
-  });
-
-  it("detects a code block with language specifier", () => {
-    const paras = makeParagraphs(["```typescript", "const x = 1;", "```"]);
-    const { codeBlockRanges } = findCodeBlockDecorations(paras);
-    expect(codeBlockRanges).toHaveLength(1);
-  });
-
-  it("dims fence delimiters", () => {
-    const paras = makeParagraphs(["```", "code", "```"]);
-    const { decorations } = findCodeBlockDecorations(paras);
-    const delimiterDecos = decorations.map(decoInfo).filter((d) => d.class === "md-delimiter");
-    // Both opening ``` and closing ``` get delimiter decorations
-    expect(delimiterDecos).toHaveLength(2);
-  });
-
-  it("does not match unclosed fences", () => {
-    const paras = makeParagraphs(["```", "code", "no closing"]);
-    const { codeBlockRanges } = findCodeBlockDecorations(paras);
-    expect(codeBlockRanges).toHaveLength(0);
-  });
-
-  it("handles multiple code blocks", () => {
-    const paras = makeParagraphs(["```", "a", "```", "text", "```", "b", "```"]);
-    const { codeBlockRanges } = findCodeBlockDecorations(paras);
-    expect(codeBlockRanges).toHaveLength(2);
-  });
-
-  it("requires closing fence to have at least as many backticks as opening", () => {
-    const paras = makeParagraphs(["````", "code", "```", "more", "````"]);
-    const { codeBlockRanges } = findCodeBlockDecorations(paras);
-    // The ``` line is not a valid close for ```` — only ```` closes it
-    expect(codeBlockRanges).toHaveLength(1);
-    // The block spans from the first ```` to the last ````
-    expect(codeBlockRanges[0]).toEqual({
-      from: paras[0].pos,
-      to: paras[4].pos + paras[4].node.nodeSize,
-    });
-  });
-
-  it("returns empty for no code blocks", () => {
-    const paras = makeParagraphs(["hello", "world"]);
-    const { decorations, codeBlockRanges } = findCodeBlockDecorations(paras);
-    expect(codeBlockRanges).toHaveLength(0);
-    expect(decorations).toHaveLength(0);
-  });
-
-  it("produces syntax highlighting decorations for inner lines", () => {
-    const paras = makeParagraphs(["```js", "const x = 1;", "```"]);
-    const { decorations } = findCodeBlockDecorations(paras);
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const classes = decorations.map((d: any) => d.type?.attrs?.class ?? d.type?.spec?.class).filter(Boolean);
-    // Should have sh- prefixed syntax highlight classes
-    expect(classes.some((c: string) => c.startsWith("sh-"))).toBe(true);
-    expect(classes).toContain("sh-keyword"); // "const" is a keyword
-  });
-});
-
-describe("highlightLine", () => {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  function decoInfo(d: any) {
-    return {
-      from: d.from,
-      to: d.to,
-      class: d.type?.attrs?.class,
-    };
-  }
-
-  it("highlights JavaScript keywords", () => {
-    const decos = highlightLine("const x = 1;", 0, "js").map(decoInfo);
-    expect(decos[0]).toEqual({ from: 0, to: 5, class: "sh-keyword" });
-  });
-
-  it("highlights strings", () => {
-    const decos = highlightLine('"hello"', 0, "js").map(decoInfo);
-    const stringDecos = decos.filter((d) => d.class === "sh-string");
-    expect(stringDecos.length).toBeGreaterThan(0);
-  });
-
-  it("highlights comments", () => {
-    const decos = highlightLine("// a comment", 0, "js").map(decoInfo);
-    expect(decos[0]?.class).toBe("sh-comment");
-  });
-
-  it("respects basePos offset", () => {
-    const decos = highlightLine("const x", 100, "js").map(decoInfo);
-    expect(decos[0]).toEqual({ from: 100, to: 105, class: "sh-keyword" });
-  });
-
-  it("returns empty for empty text", () => {
-    expect(highlightLine("", 0, "js")).toHaveLength(0);
-  });
-
-  it("works without a language specifier", () => {
-    const decos = highlightLine("const x = 1;", 0, undefined);
-    expect(decos.length).toBeGreaterThan(0);
-  });
-});
-
-describe("getLanguageOptions", () => {
-  it("returns a preset for known languages", () => {
-    expect(getLanguageOptions("python")).toBeDefined();
-    expect(getLanguageOptions("py")).toBeDefined();
-    expect(getLanguageOptions("rust")).toBeDefined();
-    expect(getLanguageOptions("go")).toBeDefined();
-    expect(getLanguageOptions("css")).toBeDefined();
-    expect(getLanguageOptions("java")).toBeDefined();
-    expect(getLanguageOptions("c")).toBeDefined();
-  });
-
-  it("is case-insensitive", () => {
-    expect(getLanguageOptions("Python")).toBeDefined();
-    expect(getLanguageOptions("RUST")).toBeDefined();
-  });
-
-  it("returns undefined for unknown languages", () => {
-    expect(getLanguageOptions("brainfuck")).toBeUndefined();
-  });
-
-  it("returns undefined for no language", () => {
-    expect(getLanguageOptions(undefined)).toBeUndefined();
-  });
-});
diff --git a/tests/unit/lib/mcp-help.test.ts b/tests/unit/lib/mcp-help.test.ts
new file mode 100644
index 00000000..374c1ead
--- /dev/null
+++ b/tests/unit/lib/mcp-help.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { mcpHelpHtml } from "~/lib/mcp-help";
+
+describe("mcpHelpHtml", () => {
+  it("embeds a normal origin into the connection snippets", () => {
+    const html = mcpHelpHtml("https://vapor.fyi");
+    expect(html).toContain("https://vapor.fyi/mcp");
+    expect(html).toContain("claude mcp add");
+  });
+
+  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/anonymous", + ); + }); + + it("never lets a hostile origin break out of its HTML context", () => { + const hostile = 'https://evil.example"'; + 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/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); + }); +}); diff --git a/tests/unit/lib/thread-serialization.test.ts b/tests/unit/lib/thread-serialization.test.ts index 3797890a..fd35917f 100644 --- a/tests/unit/lib/thread-serialization.test.ts +++ b/tests/unit/lib/thread-serialization.test.ts @@ -31,7 +31,7 @@ describe("serializeThreads", () => { const result = serializeThreads(md, threads); expect(result).toMatch(/^---\n/); - expect(result).toContain("mist:"); + expect(result).toContain("vapor:"); expect(result).toContain("threads:"); expect(result).toContain("comment: This needs work"); expect(result).toContain("author: Jane"); @@ -76,14 +76,14 @@ describe("serializeThreads", () => { expect(result).toContain("highlight: important section"); }); - it("existing frontmatter preserved (non-mist keys kept)", () => { + it("existing frontmatter preserved (non-vapor keys kept)", () => { const md = "---\ntitle: My Doc\ntags:\n - draft\n---\n\nSome text {>>A comment<<}"; const threads = [makeThread({ commentText: "A comment" })]; const result = serializeThreads(md, threads); expect(result).toContain("title: My Doc"); expect(result).toContain("tags:"); expect(result).toContain("- draft"); - expect(result).toContain("mist:"); + expect(result).toContain("vapor:"); expect(result).toContain("comment: A comment"); }); @@ -116,9 +116,9 @@ describe("serializeThreads", () => { ); // Should produce valid YAML — re-parse to verify const parsed = parseFrontmatter(result); - const mistThreads = (parsed.mist as { threads: unknown[] }).threads; - expect(mistThreads).toHaveLength(1); - expect((mistThreads[0] as { comment: string }).comment).toBe( + const vaporThreads = (parsed.vapor as { threads: unknown[] }).threads; + expect(vaporThreads).toHaveLength(1); + expect((vaporThreads[0] as { comment: string }).comment).toBe( 'Contains: colon, # hash, "quotes"', ); }); @@ -127,7 +127,7 @@ describe("serializeThreads", () => { describe("deserializeThreads", () => { it("frontmatter with one thread → ThreadData parsed", () => { const md = `--- -mist: +vapor: threads: - comment: "This needs work" author: Jane @@ -149,7 +149,7 @@ Some text {>>This needs work<<}`; it("frontmatter with replies → replies array restored", () => { const md = `--- -mist: +vapor: threads: - comment: "Fix this" author: Jane @@ -171,7 +171,7 @@ Text {>>Fix this<<}`; expect(threads[0].replies[0].text).toBe("Done"); }); - it("missing mist.threads key → empty threads array", () => { + it("missing vapor.threads key → empty threads array", () => { const md = `--- title: My Doc --- @@ -191,7 +191,7 @@ Some text`; it("thread with highlight → highlightText populated", () => { const md = `--- -mist: +vapor: threads: - comment: "Needs detail" highlight: "The intro" @@ -210,7 +210,7 @@ mist: it("malformed YAML → graceful error handling", () => { const md = `--- -mist: {{{invalid +vapor: {{{invalid --- Some text`; @@ -220,9 +220,9 @@ Some text`; expect(body).toBe("Some text"); }); - it("frontmatter with extra unknown keys under mist → preserved on roundtrip", () => { + it("frontmatter with extra unknown keys under vapor → preserved on roundtrip", () => { const md = `--- -mist: +vapor: version: 2 threads: - comment: "Note" @@ -319,7 +319,7 @@ describe("roundtrip", () => { expect(threads[0].replies[0].author.name).toBe("Bob"); }); - it("roundtrip preserves non-mist frontmatter keys", () => { + it("roundtrip preserves non-vapor frontmatter keys", () => { const md = "---\ntitle: My Doc\n---\n\nText {>>Comment<<}"; const { body } = deserializeThreads(md); const threads = [makeThread({ commentText: "Comment" })]; 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); + }); +}); 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..fb490e9b --- /dev/null +++ b/tests/unit/routes/doc-agents-route.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +const { mockRoster, mockRevoke } = vi.hoisted(() => ({ + mockRoster: vi.fn(), + mockRevoke: vi.fn(), +})); + +vi.mock("agents", () => ({ + getAgentByName: vi.fn().mockResolvedValue({ + getAgentRoster: mockRoster, + revokeAgentEntry: 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://vapor.example.com/${id}/agents`), + } as unknown as Parameters[0]; +} + +function actionArgs(id: string, body: unknown) { + return { + params: { id }, + context, + request: new Request(`https://vapor.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: "revoke", name: "scribe" }), + )) as Response; + expect(response.status).toBe(404); + }); + + 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(410); + }); + + it("revokes an agent entry", 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 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: "revoke", name: "scribe" }), + )) as Response; + expect(response.status).toBe(404); + }); + + 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 revoke", async () => { + const response = (await action(actionArgs("abcd1234", { intent: "revoke" }))) as Response; + expect(response.status).toBe(400); + }); +}); diff --git a/tests/unit/routes/doc-loader.test.ts b/tests/unit/routes/doc-loader.test.ts new file mode 100644 index 00000000..d972a497 --- /dev/null +++ b/tests/unit/routes/doc-loader.test.ts @@ -0,0 +1,75 @@ +/** + * The `/:id` document loader. Documents share the root namespace with a + * reserved-slug list, so the loader has to refuse those before it ever + * resolves a Durable Object stub. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockGetAgentByName, mockFetch } = vi.hoisted(() => ({ + mockGetAgentByName: vi.fn(), + mockFetch: vi.fn(), +})); + +vi.mock("agents", () => ({ + getAgentByName: mockGetAgentByName, +})); + +vi.mock("~/lib/cloudflare.server", () => ({ + getCloudflare: vi.fn().mockReturnValue({ env: { DocumentAgent: {} } }), +})); + +import { loader } from "~/routes/doc.$id"; + +const context = {} as Parameters[0]["context"]; + +function loaderArgs(id: string) { + return { + params: { id }, + context, + request: new Request(`https://vapor.fyi/${id}`), + } as unknown as Parameters[0]; +} + +/** The loader signals a 404 by throwing a react-router data Response. */ +async function statusOfThrow(id: string): Promise { + try { + await loader(loaderArgs(id)); + } catch (thrown) { + return (thrown as { init?: { status?: number } }).init?.status ?? 0; + } + throw new Error(`loader did not throw for id: ${id}`); +} + +describe("doc.$id loader", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAgentByName.mockResolvedValue({ fetch: mockFetch }); + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ exists: true, createdAt: 1000 })), + ); + }); + + it("404s reserved slugs without touching a Durable Object", async () => { + for (const slug of ["new", "mcp", "agents", ".well-known", "robots.txt"]) { + expect(await statusOfThrow(slug)).toBe(404); + } + expect(mockGetAgentByName).not.toHaveBeenCalled(); + }); + + it("404s a malformed document id", async () => { + expect(await statusOfThrow("nope")).toBe(404); + expect(mockGetAgentByName).not.toHaveBeenCalled(); + }); + + it("404s a well-formed id whose document doesn't exist", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ exists: false, createdAt: null })), + ); + expect(await statusOfThrow("abcd1234")).toBe(404); + }); + + it("loads an existing document", async () => { + const result = await loader(loaderArgs("abcd1234")); + expect(result).toEqual({ id: "abcd1234", createdAt: 1000 }); + }); +}); diff --git a/tests/unit/routes/new.test.ts b/tests/unit/routes/new.test.ts index cf160353..0291d23d 100644 --- a/tests/unit/routes/new.test.ts +++ b/tests/unit/routes/new.test.ts @@ -44,11 +44,11 @@ function postRequest(body: string | null, headers?: Record) { const init: RequestInit = { method: "POST" }; if (body !== null) init.body = body; if (headers) init.headers = headers; - return new Request("https://mist.example.com/new", init); + return new Request("https://vapor.example.com/new", init); } function putRequest(body: string) { - return new Request("https://mist.example.com/new", { method: "PUT", body }); + return new Request("https://vapor.example.com/new", { method: "PUT", body }); } // The action's second argument — context is passed to getCloudflare which is mocked @@ -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://vapor.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(); @@ -141,7 +141,7 @@ describe("POST /new (action)", () => { it("strips frontmatter and passes threads to agent", async () => { const md = `--- -mist: +vapor: threads: - comment: "Nice" author: "Alice" 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"); + }); +}); diff --git a/tests/unit/shared/agent-protocol.test.ts b/tests/unit/shared/agent-protocol.test.ts new file mode 100644 index 00000000..bce9e4e9 --- /dev/null +++ b/tests/unit/shared/agent-protocol.test.ts @@ -0,0 +1,129 @@ +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", () => { + 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); + }); +}); + +describe("reserved slugs", () => { + it("covers every root route and well-known path from the spec", () => { + expect(RESERVED_SLUGS).toEqual( + expect.arrayContaining([ + "new", "mcp", "agents", "api", "assets", "demo", + "favicon.ico", "robots.txt", ".well-known", + "auth", "oauth", "settings", + ]), + ); + }); + + it("matches reserved names case-insensitively", () => { + expect(isReservedSlug("new")).toBe(true); + expect(isReservedSlug(".well-known")).toBe(true); + 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"); + 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); + } + }); +}); diff --git a/tests/unit/shared/rich-markdown.test.ts b/tests/unit/shared/rich-markdown.test.ts new file mode 100644 index 00000000..5e010f09 --- /dev/null +++ b/tests/unit/shared/rich-markdown.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { + parseMarkdown, + serializePmDoc, + yDocToMarkdown, + getBlocks, + buildMarkdownBlocks, + insertBlockNodes, + resolveAnchor, + formatAnchor, + buildTypedBlock, + mintBlockId, + BLOCK_ID_RE, +} from "~/shared/rich-markdown"; +import { blockHash } from "~/shared/agent-protocol"; + +function roundTrip(md: string): string { + const parsed = parseMarkdown(md); + if (!parsed.ok) throw new Error(parsed.message); + return serializePmDoc(parsed.doc); +} + +function docFromMarkdown(md: string): Y.Doc { + const doc = new Y.Doc(); + const built = buildMarkdownBlocks(md); + if (!built.ok) throw new Error(built.message); + insertBlockNodes(doc, 0, built.nodes); + return doc; +} + +describe("markdown round-trips", () => { + const stable = [ + "Plain paragraph text.", + "# Heading one", + "## Heading two", + "### Heading three", + "Some **bold** and *italic* and ~~struck~~ and `coded` text.", + "A [link](https://vapor.fyi) inline.", + "> A quote", + "- one\n- two\n- three", + "1. first\n2. second", + "```js\nconst x = 1;\n```", + "---", + "Nested *italic with **bold** inside* run.", + "- item with **bold**\n- item with `code`", + ]; + + for (const md of stable) { + it(`stable: ${JSON.stringify(md.slice(0, 40))}`, () => { + expect(roundTrip(md)).toBe(md); + }); + } + + it("round-trip is idempotent after one normalization pass", () => { + const messy = "Heading\n=======\n\n1) item one\n2) item two\n\n_alt italic_ and __alt bold__"; + const once = roundTrip(messy); + expect(roundTrip(once)).toBe(once); + }); + + it("critic marks survive", () => { + const md = "This {++was added++} and {--was removed--} here."; + expect(roundTrip(md)).toBe(md); + }); + + it("highlight + comment pair survives", () => { + const md = "The {==stocky==}{>>rude<<} bulldog."; + expect(roundTrip(md)).toBe(md); + }); + + it("images and tables degrade to literal text without throwing", () => { + const parsed = parseMarkdown("![alt](x.png)\n\n| a | b |\n|---|---|\n| 1 | 2 |"); + expect(parsed.ok).toBe(true); + }); +}); + +describe("Y.Doc conversions", () => { + it("yDocToMarkdown matches the source markdown", () => { + const md = "# Title\n\nBody with **bold**.\n\n- a\n- b"; + expect(yDocToMarkdown(docFromMarkdown(md))).toBe(md); + }); + + it("getBlocks returns one block per top-level node with ids", () => { + const blocks = getBlocks(docFromMarkdown("# Title\n\nPara.\n\n- a\n- b")); + expect(blocks.map((b) => b.text)).toEqual(["# Title", "Para.", "- a\n- b"]); + for (const b of blocks) { + expect(b.id).toMatch(BLOCK_ID_RE); + expect(b.hash).toBe(blockHash(b.text)); + } + }); + + it("critic marks survive the Y round trip", () => { + const md = "Keep {++this++} and {==that==}{>>why?<<} intact."; + expect(yDocToMarkdown(docFromMarkdown(md))).toBe(md); + }); + + it("legacy flat paragraphs (pre-rich docs) still read", () => { + const doc = new Y.Doc(); + const frag = doc.getXmlFragment("default"); + const para = new Y.XmlElement("paragraph"); + para.insert(0, [new Y.XmlText("plain old line")]); + frag.insert(0, [para]); + const blocks = getBlocks(doc); + expect(blocks[0].text).toBe("plain old line"); + expect(blocks[0].id).toBeNull(); + }); +}); + +describe("resolveAnchor", () => { + it("resolves by block id and checks the hash", () => { + const doc = docFromMarkdown("First.\n\nSecond."); + const blocks = getBlocks(doc); + const anchor = formatAnchor(blocks[1]); + expect(resolveAnchor(doc, anchor)).toEqual({ index: 1 }); + }); + + it("returns stale_block with the current block when the hash mismatches", () => { + const doc = docFromMarkdown("First.\n\nSecond."); + const blocks = getBlocks(doc); + const stale = `${blocks[1].id}-${"0".repeat(8)}`; + const result = resolveAnchor(doc, stale); + expect(result).toMatchObject({ error: "stale_block" }); + if ("error" in result) expect(result.snippet).toContain("Second."); + }); + + it("falls back to legacy hash anchors", () => { + const doc = docFromMarkdown("First.\n\nSecond."); + const blocks = getBlocks(doc); + expect(resolveAnchor(doc, `b1-${blocks[1].hash}`)).toEqual({ index: 1 }); + }); + + it("unknown anchors are stale_anchor with an overview snippet", () => { + const doc = docFromMarkdown("First."); + const result = resolveAnchor(doc, "zzzzzzzz-00000000"); + expect(result).toMatchObject({ error: "stale_anchor" }); + }); +}); + +describe("buildTypedBlock", () => { + it("returns an empty-text skeleton plus fills that reproduce the block", () => { + const parsed = parseMarkdown("Some **bold** and plain text."); + if (!parsed.ok) throw new Error(parsed.message); + const block = parsed.doc.child(0); + const { element, fills } = buildTypedBlock(block); + + const doc = new Y.Doc(); + doc.getXmlFragment("default").insert(0, [element]); + + // Skeleton is empty + expect(yDocToMarkdown(doc)).toBe(""); + + // Typing every run reproduces the original text with formatting + for (const fill of fills) { + let offset = fill.ytext.length; + for (const run of fill.runs) { + fill.ytext.insert(offset, run.text, run.attrs as Record); + offset += run.text.length; + } + } + expect(yDocToMarkdown(doc)).toBe("Some **bold** and plain text."); + }); + + it("multi-node blocks (lists) fill item by item", () => { + const parsed = parseMarkdown("- alpha\n- beta"); + if (!parsed.ok) throw new Error(parsed.message); + const { element, fills } = buildTypedBlock(parsed.doc.child(0)); + const doc = new Y.Doc(); + doc.getXmlFragment("default").insert(0, [element]); + expect(fills).toHaveLength(2); + for (const fill of fills) { + for (const run of fill.runs) fill.ytext.insert(fill.ytext.length, run.text, run.attrs as never); + } + expect(yDocToMarkdown(doc)).toBe("- alpha\n- beta"); + }); +}); + +describe("mintBlockId", () => { + it("mints 8-char ids", () => { + for (let i = 0; i < 50; i++) expect(mintBlockId()).toMatch(BLOCK_ID_RE); + }); +}); diff --git a/tests/unit/smoke.test.ts b/tests/unit/smoke.test.ts index 47e4728e..d9f57181 100644 --- a/tests/unit/smoke.test.ts +++ b/tests/unit/smoke.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect } from "vitest"; import { APP_NAME, isValidDocumentId, + generateDocumentId, } from "~/shared/constants"; +import { isReservedSlug } from "~/shared/agent-protocol"; describe("scaffolding", () => { it("exports app name", () => { - expect(APP_NAME).toBe("mist"); + expect(APP_NAME).toBe("vapor"); }); }); @@ -23,3 +25,13 @@ describe("isValidDocumentId", () => { expect(isValidDocumentId("ABCD1234")).toBe(false); }); }); + +describe("generateDocumentId", () => { + it("mints ids that are valid and never a reserved slug", () => { + for (let i = 0; i < 500; i++) { + const id = generateDocumentId(); + expect(isValidDocumentId(id)).toBe(true); + expect(isReservedSlug(id)).toBe(false); + } + }); +}); diff --git a/tools/do-usage.mjs b/tools/do-usage.mjs new file mode 100644 index 00000000..b8f956ba --- /dev/null +++ b/tools/do-usage.mjs @@ -0,0 +1,121 @@ +#!/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 + * 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_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 + * 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_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_ANALYTICS_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.`); +} 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/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(), diff --git a/workers/app.ts b/workers/app.ts index 71e7e181..b18a19e1 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -1,16 +1,156 @@ 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, + handleAuth, + redirectHost, + redirectLegacyDocPath, + type MarkdownStub, +} from "./routes"; +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"; +export { default as Registry } from "../agents/registry"; +export { VaporMcp }; const requestHandler = createRequestHandler( () => import("virtual:react-router/server-build"), import.meta.env.MODE ); +const mcpHandler = VaporMcp.serve("/mcp", { binding: "VaporMcp" }); +const anonMcpHandler = VaporMcp.serve("/mcp/anonymous", { binding: "VaporMcp" }); + export default { async fetch(request, env, ctx) { + const url = new URL(request.url); + + // Redirect secondary domains to the primary vapor.fyi domain. Must run + // before all other handlers since it operates on the hostname level. + const redirectResponse = redirectHost(request); + if (redirectResponse) { + return redirectResponse; + } + + // Documents used to live under /docs/:id. Links shared before the move + // are still live (docs last 99 hours), so 301 them to the root-level URL + // rather than letting React Router 404 them. + const legacyDocResponse = redirectLegacyDocPath(request); + if (legacyDocResponse) { + 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/")) { + 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 + // 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 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 }; + // tracing/abort (new in recent workers-types) are unused by the MCP + // handler, so a structural cast keeps this shim minimal. + const mcpCtx = { + props, + waitUntil: (promise: Promise) => ctx.waitUntil(promise), + passThroughOnException: () => ctx.passThroughOnException(), + } as unknown as ExecutionContext; + 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 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 = { + auth: { principal: claims.principal, email: claims.email, caps: claims.caps }, + 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. + // tracing/abort (new in recent workers-types) are unused by the MCP + // handler, so a structural cast keeps this shim minimal. + const mcpCtx = { + props, + waitUntil: (promise: Promise) => ctx.waitUntil(promise), + passThroughOnException: () => ctx.passThroughOnException(), + } as unknown as ExecutionContext; + 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/workers/env.d.ts b/workers/env.d.ts new file mode 100644 index 00000000..50b32238 --- /dev/null +++ b/workers/env.d.ts @@ -0,0 +1,17 @@ +// 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 a plain var. Declared here +// so typegen output is identical with or without a .dev.vars present (CI +// has none). Runtime code still guards their absence explicitly — a secret +// can be unset in a fresh environment regardless of what the type says. +interface Env { + SESSION_SECRET: string; + GOOGLE_CLIENT_ID: string; +} + +declare namespace Cloudflare { + interface Env { + SESSION_SECRET: string; + GOOGLE_CLIENT_ID: string; + } +} diff --git a/workers/oauth.ts b/workers/oauth.ts new file mode 100644 index 00000000..d82113fd --- /dev/null +++ b/workers/oauth.ts @@ -0,0 +1,408 @@ +/** + * 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 }); +} + +/** + * 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, + 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`, + // Accept a Client ID Metadata Document URL as the client_id (CIMD), + // in addition to dynamically-registered ids. + client_id_metadata_document_supported: true, + }; +} + +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 = await resolveClient(clientId, deps); + 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; +} diff --git a/workers/routes.ts b/workers/routes.ts new file mode 100644 index 00000000..5969baa4 --- /dev/null +++ b/workers/routes.ts @@ -0,0 +1,243 @@ +/** + * 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"; +import { + mintSessionToken, + sessionFromRequest, + sessionCookieHeader, + clearSessionCookieHeader, + sameOrigin, + principalFromEmail, +} from "../app/lib/auth.server"; + +/** 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", + // 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", + }, + }); +} + +/** + * `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" && url.pathname !== "/mcp/anonymous") 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" }, + }); +} + +/** + * Redirects secondary domains to the primary vapor.fyi domain. Hostnames + * vpr.fyi, www.vpr.fyi, vaporware.fyi, www.vaporware.fyi, and www.vapor.fyi + * are 301 redirected to https://vapor.fyi with the original path and query + * string preserved. Returns null for the primary domain (vapor.fyi), + * localhost, and *.workers.dev subdomains. + */ +export function redirectHost(request: Request): Response | null { + const url = new URL(request.url); + const hostname = url.hostname; + + // The www.* entries are not (yet) registered routes for this worker — they + // only ever fire if DNS is later pointed at it, and are harmless until then. + const redirectTargets = [ + "vpr.fyi", + "www.vpr.fyi", + "vaporware.fyi", + "www.vaporware.fyi", + "www.vapor.fyi", + ]; + + if (!redirectTargets.includes(hostname)) { + return null; + } + + const targetUrl = `https://vapor.fyi${url.pathname}${url.search}`; + return new Response(null, { + status: 301, + headers: { + Location: targetUrl, + }, + }); +} + +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; avatar: string | null } }>; + getProfile: ( + principal: string, + ) => Promise<{ profile: { displayName: string; agentSlug: string | null; avatar: 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, + avatar: profile?.avatar ?? 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`). + * + * Documents used to live under `/docs/`; vapor.fyi is live and documents last + * 99 hours, so links shared before the rename are still being opened. Without + * this they 404. 301 (permanent) because the move is permanent, and the + * `Location` is path-relative so the redirect stays on whichever host served + * it — vapor.fyi, a workers.dev preview, or localhost. The query string is + * preserved verbatim. + * + * Returns null for anything else, including a `/docs/` path whose id isn't a + * valid document id, so those keep falling through to the normal 404. + */ +export function redirectLegacyDocPath(request: Request): Response | null { + if (request.method !== "GET") return null; + + const url = new URL(request.url); + const match = /^\/docs\/([^/]+?)(\.md)?$/.exec(url.pathname); + if (!match) return null; + + const id = match[1]; + if (!isValidDocumentId(id)) return null; + + const target = `/${id}${match[2] ?? ""}${url.search}`; + return new Response(null, { + status: 301, + headers: { Location: target }, + }); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 777947e1..c490bfb3 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,20 +1,32 @@ { "$schema": "node_modules/wrangler/config-schema.json", // Set CLOUDFLARE_ACCOUNT_ID env var or add your account_id here - "name": "mist", + "name": "vapor", "compatibility_date": "2025-04-04", "compatibility_flags": ["nodejs_compat"], "main": "./workers/app.ts", "observability": { "enabled": true }, + "routes": [ + { "pattern": "vapor.fyi", "custom_domain": true }, + { "pattern": "vpr.fyi", "custom_domain": true }, + { "pattern": "vaporware.fyi", "custom_domain": true } + ], "durable_objects": { "bindings": [ - { "name": "DocumentAgent", "class_name": "DocumentAgent" } + { "name": "DocumentAgent", "class_name": "DocumentAgent" }, + { "name": "VaporMcp", "class_name": "VaporMcp" }, + { "name": "Registry", "class_name": "Registry" } ] }, "migrations": [ - { "tag": "v1", "new_sqlite_classes": ["DocumentAgent"] } + { "tag": "v1", "new_sqlite_classes": ["DocumentAgent"] }, + { "tag": "v2", "new_sqlite_classes": ["VaporMcp"] }, + { "tag": "v3", "new_sqlite_classes": ["Registry"] } ], + "vars": { + "GOOGLE_CLIENT_ID": "12054056676-thqs6nurgk15kjl9mtdju83r45nhigdd.apps.googleusercontent.com" + }, "keep_vars": true }