/** * API e2e for the editor: token protocol, guards, live co-editing room (two Yjs clients + an HTTP agent), * commits from the room, PAT scope narrowing, socket revocation, and the opt-in container. * * EDITOR_E2E_URL=http://localhost:3000 GITFOR_TOKEN=gitfor_pat_... GITFOR_WRITER_TOKEN=gitfor_pat_... \ * GITFOR_E2E_D1=.wrangler/state/v3/d1/miniflare-D1DatabaseObject/.sqlite bun run e2e/editor-api.e2e.ts */ import { CLOSE_DOWNGRADED } from "../src/lib/editor-protocol"; import { BASE, call, check, log, finish, HTTP, join, LOCAL_D1, mint, PAT, quote, seedRepo, sleep, sql, until, WRITER_PAT, } from "./editor-helpers"; /** * SQL in a RepoState / AccountState database via the dev server's `/__dev/scoped-sql` route (EMAIL_DEV_SINK=kv); * returns the first column of the first row as text, like `sql` does for D1. */ async function scopedSql(scope: "repo" | "account", id: string, statement: string): Promise { const response = await fetch(`${BASE}/__dev/scoped-sql`, { method: "POST", headers: { "content-type": "application/json", origin: new URL(BASE).origin, "sec-fetch-site": "same-origin" }, body: JSON.stringify({ scope, id, sql: statement }), }); if (!response.ok) throw new Error(`scoped sql failed: ${response.status} ${await response.text()}`); const rows: Array> = await response.json(); const first = rows[0] ? Object.values(rows[0])[0] : undefined; return first === undefined || first === null ? "" : String(first); } const LFS_POINTER = `version https://git-lfs.github.com/spec/v1\noid sha256:${"a".repeat(64)}\nsize 12\n`; const BIG_FILE_BYTES = 2 * 1024 * 1024; const RECHECK_WAIT_MS = 100_000; const CURSOR_ANCHOR = 3; if (!PAT) throw new Error("Set GITFOR_TOKEN"); const repo = await seedRepo("editor-api", { "README.md": "# hello\n", "src/app.ts": "export const answer = 41;\n", "assets/big.bin": LFS_POINTER, ".gitattributes": "*.psd filter=lfs diff=lfs merge=lfs -text\n", "big.txt": "x".repeat(BIG_FILE_BYTES), "package.json": '{"scripts":{"hello":"echo hello-from-task"}}\n', }); log(`# ${repo.owner}/${repo.name} on ${BASE}`); const refresh = () => mint(repo.api, PAT); // --- Origin isolation and token protocol --------------------------------------------------------- const editorOrigin = BASE.replace("://localhost", "://editor.localhost"); const workbench = await fetch(`${editorOrigin}/workbench`); const csp = workbench.headers.get("content-security-policy") ?? ""; check( workbench.ok && csp.includes(`frame-ancestors ${BASE}`), "workbench is served on the editor origin, embeddable only by the forge", ); check( csp.includes("default-src 'none'") && !csp.includes("script-src 'self' 'unsafe-inline'"), "workbench CSP pins scripts", ); check( (await fetch(`${editorOrigin}/${repo.owner}/${repo.name}`)).status === HTTP.notFound, "forge pages aren't served on the editor origin", ); check( (await fetch(`${editorOrigin}/api/v1/user`, { headers: { authorization: `Bearer ${PAT}` } })).status === HTTP.notFound, "forge APIs aren't served on the editor origin", ); const token = await mint(repo.api, PAT); check(token.startsWith("gitfor_edt_"), "PAT mints a ref-scoped editor token"); check( (await call("/api/editor/session", PAT)).status === HTTP.unauthorized, "editor API rejects PATs (editor tokens only)", ); const session = await call("/api/editor/session", token); check(session.body.writable === true && session.body.ref === "main", "editor session is writable on main"); // --- Reads and guards ---------------------------------------------------------------------------- const big = await call("/api/editor/blob?path=big.txt", token); check( big.body.kind === "too-large" && big.body.content === null && big.body.size === BIG_FILE_BYTES, "oversized blobs report their size without content", ); const lfs = await call("/api/editor/blob?path=assets/big.bin", token); check(lfs.body.kind === "lfs", "LFS pointers are flagged, not editable text"); const commitBody = (changes: unknown) => ({ message: "x", expectedHeadSha: session.body.sha, changes }); check( ( await call("/api/editor/commit", token, { method: "POST", body: commitBody([{ path: "assets/big.bin", content: "x" }]), }) ).status === HTTP.forbidden, "commit refuses to overwrite an LFS pointer", ); check( ( await call("/api/editor/commit", token, { method: "POST", body: commitBody([{ path: "art/new.psd", content: "x" }]), }) ).status === HTTP.forbidden, "commit refuses paths .gitattributes routes through LFS", ); check( (await call("/api/editor/commit", token, { method: "POST", body: commitBody([{ path: "../x", content: "x" }]) })) .status === HTTP.conflict, "path validation applies", ); // --- Live room: two humans and an agent ---------------------------------------------------------- const alice = join(await mint(repo.api, PAT), refresh); const bob = join(await mint(repo.api, PAT), refresh); const meta = await alice.client.open("src/app.ts"); check(meta.kind === "text" && meta.state === "seeded", "room seeds a file from the branch head on open"); await bob.client.whenSynced(); alice.client.text("src/app.ts").insert(0, "// alice\n"); check( await until("bob sees alice", () => bob.client.text("src/app.ts").toString().startsWith("// alice\n")), "client B sees client A's edit", ); alice.client.awareness.setLocalState({ user: { name: "spoofed" }, path: "src/app.ts", selections: [{ anchor: CURSOR_ANCHOR, head: CURSOR_ANCHOR + 2 }], }); const aliceSeen = await until("bob sees alice's cursor", () => [...bob.client.awareness.getStates().values()].find( (state) => state.path === "src/app.ts" && state.selections?.[0]?.anchor === CURSOR_ANCHOR, ), ); check( aliceSeen?.user?.name !== "spoofed" && aliceSeen?.user?.kind === "human", "cursors sync, with identity set by the server (no spoofing)", ); const snapshot = await call("/api/editor/room/file?path=src/app.ts", token); check( snapshot.body.text.startsWith("// alice\n") && snapshot.body.participants.length >= 1, "agent reads the live text and participants", ); const agentEdit = await call("/api/editor/room/edit", token, { method: "POST", body: { path: "src/app.ts", agent: "e2e-bot", baseVersion: snapshot.body.version, edits: [{ from: 0, to: 0, insert: "// bot\n" }], }, }); check(agentEdit.status === HTTP.ok && agentEdit.body.ok, "agent edit applies against its base version"); check( await until("alice sees bot", () => alice.client.text("src/app.ts").toString().startsWith("// bot\n// alice\n")), "humans see the agent's edit live", ); const botCursor = await until("bot presence", () => [...alice.client.awareness.getStates().values()].find((state) => state.user?.kind === "agent"), ); check(botCursor?.user?.name?.startsWith("e2e-bot (agent for"), "agent shows up with a cursor and attribution"); const stale = await call("/api/editor/room/edit", token, { method: "POST", body: { path: "src/app.ts", agent: "e2e-bot", baseVersion: snapshot.body.version, edits: [{ from: 0, to: 0, insert: "x" }], }, }); check( stale.status === HTTP.conflict && stale.body.error === "stale" && typeof stale.body.text === "string", "stale agent edits return the current text to rebase on", ); const status = await call("/api/editor/room/status", token); check( status.body.changes.some((change: { path: string }) => change.path === "src/app.ts"), "room reports pending changes", ); const committed = await call("/api/editor/commit", token, { method: "POST", body: { message: "Co-edited in the room", fromRoom: true }, }); check( committed.status === HTTP.created && committed.body.branch === "main", `room commit landed as ${committed.body.sha?.slice(0, 7)}`, ); const landed = await call("/api/editor/blob?path=src/app.ts", await mint(repo.api, PAT)); check(landed.body.content === "// bot\n// alice\nexport const answer = 41;\n", "the commit contains everyone's edits"); check( await until("committed broadcast", () => bob.controls.some((message) => message.type === "committed")), "co-editors are told about the commit", ); check( await until("clean status", () => bob.controls.some((message) => message.type === "status" && message.changes.length === 0), ), "room is clean after the commit", ); // File metadata is server-owned: a client forging a file's kind is undone and disconnected. const forger = join(await mint(repo.api, PAT), refresh); await forger.client.open("src/app.ts"); forger.client.files.set("src/app.ts", { state: "seeded", kind: "binary", size: 0 }); check( await until("forger kicked", () => forger.states.includes("offline")), "a client writing file metadata is disconnected", ); check( await until("metadata restored", () => alice.client.files.get("src/app.ts")?.kind === "text"), "forged file metadata is rolled back for everyone", ); forger.client.dispose(); // Offline edits merge on reconnect. bob.client.dispose(); await alice.client.open("README.md"); alice.client.text("README.md").insert(0, "edited while bob was away\n"); const bobAgain = join(await mint(repo.api, PAT), refresh); await bobAgain.client.whenSynced(); check( await until("bob resyncs", () => bobAgain.client.text("README.md").toString().startsWith("edited while bob was away"), ), "a reconnecting client catches up", ); // --- Writer collaborator: protected branch, downgrade and revocation ------------------------------- if (WRITER_PAT && LOCAL_D1) { const writer = await call("/api/v1/user", WRITER_PAT); const writerHash = new Bun.CryptoHasher("sha256").update(WRITER_PAT).digest("hex"); // Undo anything an interrupted earlier run left behind. await scopedSql( "account", writer.body.id, `UPDATE access_tokens SET permissions = NULL WHERE token_hash = ${quote(writerHash)}`, ); sql(`UPDATE users SET sessions_revoked_at = NULL WHERE account_id = ${quote(writer.body.id)}`); await call(`${repo.api}/collaborators/${writer.body.login}`, PAT, { method: "PUT", body: { permission: "write" } }); await call(`${repo.api}/protections`, PAT, { method: "POST", body: { pattern: "main", kind: "branch" } }); await scopedSql( "repo", repo.id, `UPDATE branch_protections SET require_pull_request = 1 WHERE repo_id = ${quote(repo.id)}`, ); const writerToken = await mint(repo.api, WRITER_PAT); check( (await call("/api/editor/session", writerToken)).body.protected === true, "protected main is flagged for a collaborator", ); const writerRoom = join(writerToken, () => mint(repo.api, WRITER_PAT)); await writerRoom.client.open("src/app.ts"); writerRoom.client.text("src/app.ts").insert(0, "// writer\n"); await sleep(500); const proposal = await call("/api/editor/commit", writerToken, { method: "POST", body: { message: "Writer proposal", fromRoom: true }, }); check( proposal.status === HTTP.created && proposal.body.branch !== "main" && proposal.body.pullUrl, `protected branch commit opened ${proposal.body.pullUrl}`, ); // PAT narrowing applies to existing sessions on the next request. const patId = await scopedSql( "account", writer.body.id, `SELECT id FROM access_tokens WHERE token_hash = ${quote(writerHash)}`, ); const narrowed = join(await mint(repo.api, WRITER_PAT), () => mint(repo.api, WRITER_PAT)); await narrowed.client.whenSynced(); await scopedSql( "account", writer.body.id, `UPDATE access_tokens SET permissions = '{"contents":"read"}' WHERE id = ${quote(patId)}`, ); check( (await call("/api/editor/session", writerToken)).body.writable === false, "narrowing the PAT makes its editor sessions read-only at once", ); check( await until("downgrade close", () => narrowed.states.includes("offline"), RECHECK_WAIT_MS), `a socket from a narrowed PAT is re-authorized and downgraded (close ${CLOSE_DOWNGRADED})`, ); await scopedSql("account", writer.body.id, `UPDATE access_tokens SET permissions = NULL WHERE id = ${quote(patId)}`); // Revoking the user's sessions kicks live sockets. const kicked = join(await mint(repo.api, WRITER_PAT), () => mint(repo.api, WRITER_PAT)); await kicked.client.whenSynced(); sql(`UPDATE users SET sessions_revoked_at = ${Date.now() + 1000} WHERE account_id = ${quote(writer.body.id)}`); check( await until("revoked kick", () => kicked.states.includes("revoked"), RECHECK_WAIT_MS), "a revoked user's socket is closed within the re-check interval", ); sql(`UPDATE users SET sessions_revoked_at = NULL WHERE account_id = ${quote(writer.body.id)}`); for (const client of [writerRoom, narrowed, kicked]) client.client.dispose(); } // --- Run in container ------------------------------------------------------------------------------ const containerToken = await mint(repo.api, PAT); const started = await call("/api/editor/container/start", containerToken, { method: "POST", body: {} }); if (started.status === HTTP.forbidden) { log("# containers disabled on this forge; skipping container checks"); } else { check(started.status === HTTP.ok && started.body.state === "running", "container starts with the branch checked out"); await call("/api/editor/room/edit", containerToken, { method: "POST", body: { path: "notes.txt", agent: "e2e-bot", edits: [{ from: 0, to: 0, insert: "from the room\n" }] }, }); const synced = await call("/api/editor/container/sync", containerToken, { method: "POST", body: {} }); check(synced.body?.written >= 1, `room edits sync into the container (${JSON.stringify(synced.body)})`); const socket = new WebSocket( `${BASE.replace(/^http/, "ws")}/api/editor/container/terminal?command=${encodeURIComponent("cat notes.txt && npm run -s hello")}`, ["gitfor-editor.v1", containerToken], ); socket.binaryType = "arraybuffer"; let output = ""; await new Promise((resolve) => { socket.onmessage = (event) => { if (typeof event.data !== "string") output += new TextDecoder().decode(event.data as ArrayBuffer); }; socket.onclose = () => resolve(); setTimeout(resolve, 60_000); }); check( output.includes("from the room") && output.includes("hello-from-task"), "terminal/task runs in the container with live edits", ); const stopped = await call("/api/editor/container/stop", containerToken, { method: "POST", body: {} }); check( typeof stopped.body.seconds === "number" && stopped.body.seconds > 0, `stopping meters the exact seconds run (${stopped.body.seconds})`, ); if (LOCAL_D1) { check( (await scopedSql( "account", (await call("/api/v1/user", PAT)).body.id, `SELECT COUNT(*) FROM editor_container_sessions WHERE repo_id = ${quote(repo.id)} AND stopped_at IS NULL`, )) === "0", "no container session is left open", ); } } alice.client.dispose(); bobAgain.client.dispose(); await call(repo.api, PAT, { method: "DELETE" }); finish();